1use std::collections::HashSet;
18use std::sync::Arc;
19
20use api::helper::{to_grpc_value, vectors_to_rows};
21use api::v1::greptime_request::Request;
22use api::v1::{RowInsertRequest, RowInsertRequests, Rows};
23use common_error::ext::BoxedError;
24use common_query::OutputData;
25use common_recordbatch::{RecordBatch, RecordBatches, map_dictionary_to_values_data_type};
26use common_time::Timestamp;
27use datafusion::catalog::MemTable;
28use datafusion::datasource::{TableProvider, provider_as_source, source_as_provider};
29use datafusion_common::tree_node::{Transformed, TreeNode};
30use datafusion_common::{Column, DFSchema, TableReference};
31use datafusion_expr::logical_plan::{Distinct, Projection};
32use datafusion_expr::{Expr, LogicalPlan};
33use datatypes::schema::{ColumnSchema, SchemaRef};
34use datatypes::value::Value;
35use query::QueryEngine;
36use session::context::QueryContextRef;
37use snafu::{OptionExt, ResultExt, ensure};
38use table::metadata::TableId;
39use table::table::adapter::DfTableProviderAdapter;
40
41use crate::TableName;
42use crate::adapter::util::column_schemas_to_proto;
43use crate::batching_mode::frontend_client::FrontendClient;
44use crate::error::{DatafusionSnafu, Error, ExternalSnafu, InvalidQuerySnafu, UnexpectedSnafu};
45use crate::repr::DiffRow;
46
47#[derive(Clone)]
49pub(crate) struct StatelessFlow {
50 pub(crate) source_table_id: TableId,
51 pub(crate) source_table_name: TableName,
52 pub(crate) source_schema: SchemaRef,
53 pub(crate) source_schema_version: u32,
54 pub(crate) sink_table_name: TableName,
55 pub(crate) sink_schema: Vec<ColumnSchema>,
56 pub(crate) sink_primary_keys: Vec<String>,
57 pub(crate) auto_columns: Vec<ColumnSchema>,
59 pub(crate) plan: LogicalPlan,
60 pub(crate) query_ctx: QueryContextRef,
61 pub(crate) create_args: crate::CreateFlowArgs,
62}
63
64#[cfg(test)]
66fn test_source_plan(table_name: TableReference, provider: Arc<dyn TableProvider>) -> LogicalPlan {
67 datafusion_expr::LogicalPlanBuilder::scan(table_name, provider_as_source(provider), None)
68 .unwrap()
69 .filter(datafusion_expr::col("number").gt(datafusion_expr::lit(1)))
70 .unwrap()
71 .project(vec![datafusion_expr::col("number")])
72 .unwrap()
73 .build()
74 .unwrap()
75}
76
77fn input_provider(batch: &RecordBatch) -> Result<Arc<dyn TableProvider>, Error> {
78 let arrow_batch = batch.df_record_batch().clone();
79 let provider = MemTable::try_new(arrow_batch.schema(), vec![vec![arrow_batch]]).context(
80 DatafusionSnafu {
81 context: "Failed to create transient flow input provider",
82 },
83 )?;
84 Ok(Arc::new(provider))
85}
86
87pub(crate) fn rewrite_source_timestamp(
91 plan: LogicalPlan,
92 source_name: &TableReference,
93 source_timestamp_name: &str,
94) -> Result<LogicalPlan, Error> {
95 let mut names = HashSet::new();
96 plan.apply(|node| {
97 names.extend(
98 node.schema()
99 .fields()
100 .iter()
101 .map(|field| field.name().clone()),
102 );
103 Ok(datafusion_common::tree_node::TreeNodeRecursion::Continue)
104 })
105 .context(DatafusionSnafu {
106 context: "Failed to inspect streaming flow plan schema",
107 })?;
108 let mut hidden_name = "__flow_source_timestamp".to_string();
109 let mut suffix = 0;
110 while !names.insert(hidden_name.clone()) {
111 suffix += 1;
112 hidden_name = format!("__flow_source_timestamp_{suffix}");
113 }
114 let visible_count = plan.schema().fields().len();
115 let source_timestamp = Column::from_name(source_timestamp_name);
116 let mut plan = plan
117 .transform_up_with_subqueries(|node| match node {
118 LogicalPlan::TableScan(mut scan) => {
119 if scan.table_name.resolved_eq(source_name)
120 && let Some(projection) = &mut scan.projection
121 {
122 let timestamp_index = scan
123 .source
124 .schema()
125 .index_of(source_timestamp_name)
126 .map_err(|_| {
127 datafusion::error::DataFusionError::Plan(
128 "Source timestamp is absent from source scan".into(),
129 )
130 })?;
131 if !projection.contains(×tamp_index) {
132 projection.push(timestamp_index);
133 let schema = scan.source.schema();
134 scan.projected_schema = Arc::new(DFSchema::new_with_metadata(
135 projection
136 .iter()
137 .map(|index| {
138 (
139 Some(scan.table_name.clone()),
140 Arc::new(schema.field(*index).clone()),
141 )
142 })
143 .collect(),
144 schema.metadata().clone(),
145 )?);
146 }
147 }
148 Ok(Transformed::yes(LogicalPlan::TableScan(scan)))
149 }
150 LogicalPlan::Projection(mut projection) => {
151 let hidden_expr = if projection
152 .input
153 .schema()
154 .fields()
155 .iter()
156 .any(|field| field.name() == &hidden_name)
157 {
158 Expr::Column(Column::from_name(hidden_name.clone()))
159 } else {
160 Expr::Column(source_timestamp.clone())
161 };
162 projection.expr.push(hidden_expr.alias(hidden_name.clone()));
163 let projection = Projection::try_new(projection.expr, projection.input)?;
164 Ok(Transformed::yes(LogicalPlan::Projection(projection)))
165 }
166 _ => Ok(Transformed::no(node)),
167 })
168 .context(DatafusionSnafu {
169 context: "Failed to add source timestamp to streaming flow plan",
170 })?
171 .data;
172
173 if !plan
176 .schema()
177 .fields()
178 .iter()
179 .any(|field| field.name() == &hidden_name)
180 {
181 let expressions = plan
182 .schema()
183 .fields()
184 .iter()
185 .take(visible_count)
186 .map(|field| Expr::Column(Column::from_name(field.name())))
187 .chain(std::iter::once(
188 Expr::Column(source_timestamp).alias(hidden_name),
189 ))
190 .collect::<Vec<_>>();
191 plan = Projection::try_new(expressions, Arc::new(plan))
192 .map(LogicalPlan::Projection)
193 .context(DatafusionSnafu {
194 context: "Failed to finalize source timestamp in streaming flow plan",
195 })?;
196 }
197 Ok(plan)
198}
199
200fn replace_source(
201 plan: LogicalPlan,
202 source_name: &TableReference,
203 provider: Arc<dyn TableProvider>,
204) -> Result<LogicalPlan, Error> {
205 let mut scan_count = 0;
206 let mut replaced_scan_count = 0;
207 let plan = plan
208 .transform_up_with_subqueries(|node| match node {
209 LogicalPlan::TableScan(mut scan) => {
210 scan_count += 1;
211 if scan.table_name.resolved_eq(source_name) {
212 replaced_scan_count += 1;
213 scan.source = provider_as_source(provider.clone());
214 let schema = scan.source.schema();
215 scan.projected_schema = if let Some(projection) = &scan.projection {
216 Arc::new(DFSchema::new_with_metadata(
217 projection
218 .iter()
219 .map(|index| {
220 (
221 Some(scan.table_name.clone()),
222 Arc::new(schema.field(*index).clone()),
223 )
224 })
225 .collect(),
226 schema.metadata().clone(),
227 )?)
228 } else {
229 Arc::new(DFSchema::try_from_qualified_schema(
230 scan.table_name.clone(),
231 &schema,
232 )?)
233 };
234 Ok(Transformed::yes(LogicalPlan::TableScan(scan)))
235 } else {
236 Ok(Transformed::no(LogicalPlan::TableScan(scan)))
237 }
238 }
239 LogicalPlan::Join(_) | LogicalPlan::Aggregate(_) => {
240 Err(datafusion::error::DataFusionError::Plan(
241 "Streaming flow supports only a single projection/filter source".into(),
242 ))
243 }
244 _ => Ok(Transformed::no(node)),
245 })
246 .context(DatafusionSnafu {
247 context: "Failed to substitute transient flow input provider",
248 })?
249 .data;
250 ensure!(
251 scan_count == 1 && replaced_scan_count == 1,
252 InvalidQuerySnafu {
253 reason: format!(
254 "Expected one source scan matching {:?}, found {scan_count} scans and {replaced_scan_count} matches",
255 source_name
256 )
257 }
258 );
259 Ok(plan)
260}
261
262pub(crate) fn validate_plan(plan: &LogicalPlan) -> Result<(), Error> {
264 let mut scans = 0;
265 plan.apply(|node| {
266 match node {
267 LogicalPlan::TableScan(_) => scans += 1,
268 LogicalPlan::Projection(_) | LogicalPlan::Filter(_) => {}
269 LogicalPlan::Aggregate(_) => {
270 return Err(datafusion::error::DataFusionError::Plan(
271 "Aggregation is unsupported in streaming flows. Recreate the flow to select batching mode. A source table with TTL=instant must use persisted retention first. Aggregation SQL without a time window requires EVAL INTERVAL.".into(),
272 ));
273 }
274 LogicalPlan::Distinct(Distinct::All(_)) => {}
277 _ => {
278 return Err(datafusion::error::DataFusionError::Plan(
279 "Streaming flow supports only projection and filter over one source scan"
280 .into(),
281 ));
282 }
283 }
284 Ok(datafusion_common::tree_node::TreeNodeRecursion::Continue)
285 })
286 .context(DatafusionSnafu {
287 context: "Failed to validate streaming flow plan",
288 })?;
289 ensure!(
290 scans == 1,
291 InvalidQuerySnafu {
292 reason: format!("Expected one source scan, found {scans}")
293 }
294 );
295 Ok(())
296}
297
298pub(crate) fn validate_source_scan(
300 plan: &LogicalPlan,
301 source_table_id: TableId,
302 source_schema: &SchemaRef,
303) -> Result<(), Error> {
304 plan.apply(|node| {
305 if let LogicalPlan::TableScan(scan) = node {
306 let provider = source_as_provider(&scan.source)?;
307 let provider = provider
308 .downcast_ref::<DfTableProviderAdapter>()
309 .ok_or_else(|| {
310 datafusion::error::DataFusionError::Plan(
311 "Streaming flow source scan does not use a table provider".into(),
312 )
313 })?;
314 let table_info = provider.table().table_info();
315 if table_info.ident.table_id != source_table_id
316 || table_info.meta.schema.as_ref() != source_schema.as_ref()
317 {
318 return Err(datafusion::error::DataFusionError::Plan(format!(
319 "Streaming flow source scan does not match source table {source_table_id} schema version {}",
320 source_schema.version()
321 )));
322 }
323 }
324 Ok(datafusion_common::tree_node::TreeNodeRecursion::Continue)
325 })
326 .context(DatafusionSnafu {
327 context: "Failed to validate streaming flow source scan",
328 })?;
329 Ok(())
330}
331
332fn validate_source_schema_version(
336 retained_version: u32,
337 current_version: u32,
338) -> Result<(), Error> {
339 ensure!(
340 retained_version == current_version,
341 InvalidQuerySnafu {
342 reason: format!(
343 "Source schema version changed from {retained_version} to {current_version}; recreate or recover the flow before writing"
344 )
345 }
346 );
347 Ok(())
348}
349
350fn synthesize_auto_values(columns: &[ColumnSchema], now: Timestamp) -> Result<Vec<Value>, Error> {
351 columns
352 .iter()
353 .map(|column| {
354 let timestamp_type = column.data_type.as_timestamp().context(InvalidQuerySnafu {
355 reason: format!("Auto sink column {} is not a timestamp", column.name),
356 })?;
357 let value = if column.name == crate::adapter::AUTO_CREATED_UPDATE_AT_TS_COL {
358 now.convert_to(timestamp_type.unit())
359 .context(InvalidQuerySnafu {
360 reason: "Current timestamp cannot be represented in sink timestamp unit",
361 })?
362 } else if column.name == crate::adapter::AUTO_CREATED_PLACEHOLDER_TS_COL {
363 Timestamp::new(0, timestamp_type.unit())
364 } else {
365 return InvalidQuerySnafu {
366 reason: format!("Unsupported auto sink column {}", column.name),
367 }
368 .fail();
369 };
370 Ok(Value::Timestamp(value))
371 })
372 .collect()
373}
374
375pub(crate) async fn execute(
377 flow: &StatelessFlow,
378 rows: &[DiffRow],
379 batch_datatypes: &[datatypes::data_type::ConcreteDataType],
380 query_engine: &Arc<dyn QueryEngine>,
381 frontend_client: &Arc<FrontendClient>,
382 current_source_schema_version: u32,
383) -> Result<usize, Error> {
384 validate_source_schema_version(flow.source_schema_version, current_source_schema_version)?;
385 let values = rows.iter().map(|(row, _, _)| row.clone()).collect();
386 let batch = crate::expr::Batch::try_from_rows_with_types(values, batch_datatypes)
387 .map_err(BoxedError::new)
388 .context(ExternalSnafu)?;
389 let batch = RecordBatch::new(flow.source_schema.clone(), batch.batch().to_vec())
390 .map_err(BoxedError::new)
391 .context(ExternalSnafu)?;
392 let provider = input_provider(&batch)?;
393 let source_ref = TableReference::full(
394 flow.source_table_name[0].clone(),
395 flow.source_table_name[1].clone(),
396 flow.source_table_name[2].clone(),
397 );
398 let plan = replace_source(flow.plan.clone(), &source_ref, provider)?;
399 let output = query_engine
400 .execute(plan, flow.query_ctx.clone())
401 .await
402 .map_err(BoxedError::new)
403 .context(ExternalSnafu)?;
404 let batches = match output.data {
405 OutputData::RecordBatches(batches) => batches,
406 OutputData::Stream(stream) => RecordBatches::try_collect(stream)
407 .await
408 .map_err(BoxedError::new)
409 .context(ExternalSnafu)?,
410 OutputData::AffectedRows(_) => {
411 return UnexpectedSnafu {
412 reason: "Streaming flow query returned affected rows",
413 }
414 .fail();
415 }
416 };
417
418 let output_schema = batches
419 .schema()
420 .column_schemas()
421 .iter()
422 .cloned()
423 .map(|mut column| {
424 column.data_type = map_dictionary_to_values_data_type(&column.data_type);
425 column
426 })
427 .collect::<Vec<_>>();
428 crate::adapter::validate_sink_layout_with_suffix(
429 &output_schema,
430 &flow.sink_schema,
431 &flow.auto_columns,
432 )?;
433 let mut output_rows = Vec::new();
434 for batch in batches {
435 let vectors = datatypes::vectors::Helper::try_into_vectors(batch.columns())
436 .map_err(BoxedError::new)
437 .context(ExternalSnafu)?;
438 output_rows.extend(vectors_to_rows(vectors.iter(), batch.num_rows()));
439 }
440 if output_rows.is_empty() {
441 return Ok(0);
442 }
443
444 let auto_values = synthesize_auto_values(&flow.auto_columns, Timestamp::current_millis())?
447 .into_iter()
448 .map(to_grpc_value)
449 .collect::<Vec<_>>();
450 for row in &mut output_rows {
451 row.values.extend(auto_values.iter().cloned());
452 }
453
454 let output_row_count = output_rows.len();
455 let proto_schema = column_schemas_to_proto(flow.sink_schema.clone(), &flow.sink_primary_keys)?;
456 let request = Request::RowInserts(RowInsertRequests {
457 inserts: vec![RowInsertRequest {
458 table_name: flow.sink_table_name[2].clone(),
459 rows: Some(Rows {
460 schema: proto_schema,
461 rows: output_rows,
462 }),
463 }],
464 });
465 let mut peer = None;
466 frontend_client
467 .handle_insert_once(
468 request,
469 &flow.sink_table_name[0],
470 &flow.sink_table_name[1],
471 &mut peer,
472 )
473 .await
474 .map_err(BoxedError::new)
475 .context(ExternalSnafu)?;
476 Ok(output_row_count)
477}
478
479#[cfg(test)]
480mod tests {
481 use datafusion::catalog::MemTable;
482 use datafusion::logical_expr::LogicalPlanBuilder;
483 use datatypes::data_type::ConcreteDataType;
484 use datatypes::schema::{ColumnSchema, Schema};
485 use datatypes::vectors::{Int32Vector, TimestampMillisecondVector};
486 use session::context::QueryContext;
487
488 use super::*;
489
490 #[test]
491 fn validation_accepts_distinct_over_one_source() {
492 let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
493 "number",
494 ConcreteDataType::int32_datatype(),
495 false,
496 )]));
497 let plan = LogicalPlanBuilder::scan(
498 TableReference::bare("source"),
499 provider_as_source(provider(&schema, 1)),
500 None,
501 )
502 .unwrap()
503 .project(vec![datafusion_expr::col("number")])
504 .unwrap()
505 .distinct()
506 .unwrap()
507 .build()
508 .unwrap();
509 assert!(validate_plan(&plan).is_ok());
510 }
511
512 #[test]
513 fn validation_rejects_plan_without_source_scan() {
514 let plan = LogicalPlan::EmptyRelation(datafusion_expr::logical_plan::EmptyRelation {
515 produce_one_row: false,
516 schema: Arc::new(DFSchema::empty()),
517 });
518 assert!(validate_plan(&plan).is_err());
519 }
520
521 fn provider(schema: &SchemaRef, value: i32) -> Arc<dyn TableProvider> {
522 let batch = RecordBatch::new(
523 schema.clone(),
524 vec![Arc::new(Int32Vector::from_slice([value])) as datatypes::prelude::VectorRef],
525 )
526 .unwrap();
527 let arrow = batch.df_record_batch().clone();
528 Arc::new(MemTable::try_new(arrow.schema(), vec![vec![arrow]]).unwrap())
529 }
530
531 #[test]
532 fn source_schema_version_must_match_retained_plan() {
533 assert!(validate_source_schema_version(7, 7).is_ok());
534
535 let error = validate_source_schema_version(7, 8).unwrap_err();
536 assert!(matches!(error, Error::InvalidQuery { reason, .. } if
537 reason.contains("Source schema version changed from 7 to 8")
538 && reason.contains("recreate or recover")
539 ));
540 }
541
542 #[test]
543 fn auto_values_use_the_sink_timestamp_units() {
544 let update_at = ColumnSchema::new(
545 crate::adapter::AUTO_CREATED_UPDATE_AT_TS_COL,
546 datatypes::data_type::ConcreteDataType::timestamp_second_datatype(),
547 true,
548 );
549 let placeholder = ColumnSchema::new(
550 crate::adapter::AUTO_CREATED_PLACEHOLDER_TS_COL,
551 datatypes::data_type::ConcreteDataType::timestamp_nanosecond_datatype(),
552 true,
553 );
554 let values = synthesize_auto_values(
555 &[update_at],
556 Timestamp::new(1_234, common_time::timestamp::TimeUnit::Millisecond),
557 )
558 .unwrap();
559 assert_eq!(values.len(), 1);
560 assert_eq!(
561 values[0].as_timestamp().unwrap().unit(),
562 common_time::timestamp::TimeUnit::Second
563 );
564 assert!(values[0].as_timestamp().unwrap().value() > 0);
565
566 let values = synthesize_auto_values(
567 &[
568 ColumnSchema::new(
569 crate::adapter::AUTO_CREATED_UPDATE_AT_TS_COL,
570 datatypes::data_type::ConcreteDataType::timestamp_microsecond_datatype(),
571 true,
572 ),
573 placeholder,
574 ],
575 Timestamp::new(1_234, common_time::timestamp::TimeUnit::Millisecond),
576 )
577 .unwrap();
578 assert_eq!(values[1].as_timestamp().unwrap().value(), 0);
579 assert_eq!(
580 values[1].as_timestamp().unwrap().unit(),
581 common_time::timestamp::TimeUnit::Nanosecond
582 );
583 }
584
585 #[test]
586 fn auto_values_reject_arbitrary_columns() {
587 let column = ColumnSchema::new(
588 "other",
589 datatypes::data_type::ConcreteDataType::timestamp_millisecond_datatype(),
590 true,
591 );
592 assert!(synthesize_auto_values(&[column], Timestamp::current_millis()).is_err());
593 }
594
595 #[test]
596 fn rewrite_source_timestamp_appends_collision_free_hidden_output() {
597 let schema = Arc::new(Schema::new(vec![
598 ColumnSchema::new("number", ConcreteDataType::int32_datatype(), false),
599 ColumnSchema::new(
600 "ts",
601 ConcreteDataType::timestamp_millisecond_datatype(),
602 false,
603 )
604 .with_time_index(true),
605 ColumnSchema::new(
606 "__flow_source_timestamp",
607 ConcreteDataType::int32_datatype(),
608 false,
609 ),
610 ]));
611 let batch = RecordBatch::new(
612 schema.clone(),
613 vec![
614 Arc::new(Int32Vector::from_slice([2])) as datatypes::prelude::VectorRef,
615 Arc::new(TimestampMillisecondVector::from_slice([42]))
616 as datatypes::prelude::VectorRef,
617 Arc::new(Int32Vector::from_slice([7])) as datatypes::prelude::VectorRef,
618 ],
619 )
620 .unwrap();
621 let plan = datafusion_expr::LogicalPlanBuilder::scan(
622 TableReference::bare("source"),
623 provider_as_source(input_provider(&batch).unwrap()),
624 None,
625 )
626 .unwrap()
627 .filter(datafusion_expr::col("number").gt(datafusion_expr::lit(1)))
628 .unwrap()
629 .project(vec![datafusion_expr::col("number")])
630 .unwrap()
631 .build()
632 .unwrap();
633 let rewritten =
634 rewrite_source_timestamp(plan, &TableReference::bare("source"), "ts").unwrap();
635 assert_eq!(rewritten.schema().fields().len(), 2);
636 assert!(
637 rewritten
638 .schema()
639 .field(1)
640 .name()
641 .starts_with("__flow_source_timestamp")
642 );
643 }
644
645 #[tokio::test]
646 async fn finite_projection_filter_does_not_retain_previous_batch() {
647 let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
648 "number",
649 ConcreteDataType::int32_datatype(),
650 false,
651 )]));
652 let initial_provider = provider(&schema, 0);
653 let plan = test_source_plan(TableReference::bare("source"), initial_provider);
654 assert!(validate_plan(&plan).is_ok());
655
656 let engine = crate::test_utils::create_test_query_engine();
657 let run = |input: Arc<dyn TableProvider>| {
658 let engine = engine.clone();
659 let plan = plan.clone();
660 async move {
661 let plan = replace_source(plan, &TableReference::bare("source"), input).unwrap();
662 let output = engine.execute(plan, QueryContext::arc()).await.unwrap();
663 match output.data {
664 OutputData::Stream(stream) => RecordBatches::try_collect(stream)
665 .await
666 .unwrap()
667 .iter()
668 .map(|batch| batch.num_rows())
669 .sum(),
670 OutputData::RecordBatches(batches) => {
671 batches.iter().map(|batch| batch.num_rows()).sum()
672 }
673 OutputData::AffectedRows(_) => 0,
674 }
675 }
676 };
677
678 assert_eq!(run(provider(&schema, 1)).await, 0);
679 assert_eq!(run(provider(&schema, 2)).await, 1);
680 }
681
682 #[tokio::test]
683 async fn finite_distinct_collapses_duplicates_per_request() {
684 let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
685 "number",
686 ConcreteDataType::int32_datatype(),
687 false,
688 )]));
689 let input = |values: &[i32]| {
690 let batch = RecordBatch::new(
691 schema.clone(),
692 vec![Arc::new(Int32Vector::from_slice(values)) as datatypes::prelude::VectorRef],
693 )
694 .unwrap();
695 input_provider(&batch).unwrap()
696 };
697 let plan = LogicalPlanBuilder::scan(
698 TableReference::bare("source"),
699 provider_as_source(input(&[1])),
700 None,
701 )
702 .unwrap()
703 .project(vec![datafusion_expr::col("number")])
704 .unwrap()
705 .distinct()
706 .unwrap()
707 .build()
708 .unwrap();
709 assert!(validate_plan(&plan).is_ok());
710
711 let engine = crate::test_utils::create_test_query_engine();
712 let run = |provider: Arc<dyn TableProvider>| {
713 let engine = engine.clone();
714 let plan = plan.clone();
715 async move {
716 let plan = replace_source(plan, &TableReference::bare("source"), provider).unwrap();
717 let output = engine.execute(plan, QueryContext::arc()).await.unwrap();
718 let batches = match output.data {
719 OutputData::Stream(stream) => RecordBatches::try_collect(stream).await.unwrap(),
720 OutputData::RecordBatches(batches) => batches,
721 OutputData::AffectedRows(_) => panic!("unexpected affected rows"),
722 };
723 batches.iter().map(|batch| batch.num_rows()).sum::<usize>()
724 }
725 };
726
727 assert_eq!(run(input(&[1, 1, 2])).await, 2);
728 assert_eq!(run(input(&[1, 1])).await, 1);
729 }
730}