1use std::collections::HashMap;
16use std::marker::PhantomData;
17use std::sync::Arc;
18
19use anyhow::{Context, anyhow};
20use itertools::Itertools;
21use risingwave_common::bitmap::{Bitmap, BitmapBuilder};
22use risingwave_common::catalog::{ColumnDesc, Field, Schema};
23use risingwave_common::hash::table_distribution::TableDistribution;
24use risingwave_common::hash::{
25 ExpandedWorkerSlotMapping, HashKey, HashKeyDispatcher, VirtualNode, VnodeCountCompat,
26 WorkerSlotId,
27};
28use risingwave_common::memory::MemoryContext;
29use risingwave_common::types::{DataType, Datum};
30use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
31use risingwave_common::util::iter_util::ZipEqFast;
32use risingwave_common::util::scan_range::ScanRange;
33use risingwave_common::util::tracing::TracingContext;
34use risingwave_expr::expr::{BoxedExpression, build_from_prost};
35use risingwave_pb::batch_plan::exchange_info::DistributionMode;
36use risingwave_pb::batch_plan::exchange_source::LocalExecutePlan::Plan;
37use risingwave_pb::batch_plan::plan_node::NodeBody;
38use risingwave_pb::batch_plan::{
39 ExchangeInfo, ExchangeNode, LocalExecutePlan, PbExchangeSource, PbTaskId, PlanFragment,
40 PlanNode, RowSeqScanNode, TaskOutputId,
41};
42use risingwave_pb::common::{BatchQueryEpoch, WorkerNode};
43use risingwave_pb::plan_common::StorageTableDesc;
44
45use super::AsOfDesc;
46use crate::error::Result;
47use crate::executor::{
48 BoxedDataChunkStream, BoxedExecutor, BoxedExecutorBuilder, DummyExecutor, Executor,
49 ExecutorBuilder, JoinType, LookupJoinBase,
50};
51use crate::task::{BatchTaskContext, ShutdownToken, TaskId};
52
53struct InnerSideExecutorBuilder {
55 table_desc: StorageTableDesc,
56 table_distribution: TableDistribution,
57 vnode_mapping: ExpandedWorkerSlotMapping,
58 outer_side_key_types: Vec<DataType>,
59 inner_side_schema: Schema,
60 inner_side_column_ids: Vec<i32>,
61 inner_side_key_types: Vec<DataType>,
62 lookup_prefix_len: usize,
63 context: Arc<dyn BatchTaskContext>,
64 task_id: TaskId,
65 query_epoch: BatchQueryEpoch,
66 worker_slot_mapping: HashMap<WorkerSlotId, WorkerNode>,
67 worker_slot_to_scan_range_mapping: HashMap<WorkerSlotId, Vec<(ScanRange, VirtualNode)>>,
68 #[expect(dead_code)]
69 chunk_size: usize,
70 shutdown_rx: ShutdownToken,
71 next_stage_id: usize,
72}
73
74pub trait LookupExecutorBuilder: Send + 'static {
76 fn reset(&mut self);
77
78 fn add_scan_range(
79 &mut self,
80 key_datums: Vec<Datum>,
81 ) -> impl Future<Output = Result<()>> + Send + '_;
82
83 fn build_executor(&mut self) -> impl Future<Output = Result<BoxedExecutor>> + Send + '_;
84}
85
86impl InnerSideExecutorBuilder {
87 fn get_virtual_node(&self, scan_range: &ScanRange) -> Result<VirtualNode> {
89 let virtual_node = scan_range
90 .try_compute_vnode(&self.table_distribution)
91 .context("Could not compute vnode for lookup join")?;
92 Ok(virtual_node)
93 }
94
95 fn create_row_seq_scan_node(&self, id: &WorkerSlotId) -> Result<NodeBody> {
98 let list = self.worker_slot_to_scan_range_mapping.get(id).unwrap();
99 let mut scan_ranges = vec![];
100 let mut vnode_bitmap = BitmapBuilder::zeroed(self.vnode_mapping.len());
101
102 list.iter().for_each(|(scan_range, vnode)| {
103 scan_ranges.push(scan_range.to_protobuf());
104 vnode_bitmap.set(vnode.to_index(), true);
105 });
106
107 let row_seq_scan_node = NodeBody::RowSeqScan(RowSeqScanNode {
108 table_desc: Some(self.table_desc.clone()),
109 column_ids: self.inner_side_column_ids.clone(),
110 scan_ranges,
111 ordered: false,
112 vnode_bitmap: Some(vnode_bitmap.finish().to_protobuf()),
113 limit: None,
114 query_epoch: Some(self.query_epoch),
115 });
116
117 Ok(row_seq_scan_node)
118 }
119
120 fn build_prost_exchange_source(&self, id: &WorkerSlotId) -> Result<PbExchangeSource> {
122 let worker = self
123 .worker_slot_mapping
124 .get(id)
125 .context("No worker node found for the given worker slot id.")?;
126
127 let local_execute_plan = LocalExecutePlan {
128 plan: Some(PlanFragment {
129 root: Some(PlanNode {
130 children: vec![],
131 identity: "SeqScan".to_owned(),
132 node_body: Some(self.create_row_seq_scan_node(id)?),
133 }),
134 exchange_info: Some(ExchangeInfo {
135 mode: DistributionMode::Single as i32,
136 ..Default::default()
137 }),
138 }),
139 tracing_context: TracingContext::from_current_span().to_protobuf(),
140 };
141
142 let prost_exchange_source = PbExchangeSource {
143 task_output_id: Some(TaskOutputId {
144 task_id: Some(PbTaskId {
145 query_id: self.task_id.query_id.clone(),
146 stage_id: self.task_id.stage_id + 10000 + self.next_stage_id as u32,
148 task_id: (*id).into(),
149 }),
150 output_id: 0,
151 }),
152 host: Some(worker.host.as_ref().unwrap().clone()),
153 local_execute_plan: Some(Plan(local_execute_plan)),
154 };
155
156 Ok(prost_exchange_source)
157 }
158}
159
160impl LookupExecutorBuilder for InnerSideExecutorBuilder {
161 fn reset(&mut self) {
162 self.worker_slot_to_scan_range_mapping = HashMap::new();
163 }
164
165 async fn add_scan_range(&mut self, key_datums: Vec<Datum>) -> Result<()> {
168 let mut scan_range = ScanRange::full_table_scan();
169
170 for ((datum, outer_type), inner_type) in key_datums
171 .into_iter()
172 .zip_eq_fast(
173 self.outer_side_key_types
174 .iter()
175 .take(self.lookup_prefix_len),
176 )
177 .zip_eq_fast(
178 self.inner_side_key_types
179 .iter()
180 .take(self.lookup_prefix_len),
181 )
182 {
183 let datum = if inner_type == outer_type {
184 datum
185 } else {
186 bail!("Join key types are not aligned: LHS: {outer_type:?}, RHS: {inner_type:?}");
187 };
188
189 scan_range.eq_conds.push(datum);
190 }
191
192 let vnode = self.get_virtual_node(&scan_range)?;
193 let worker_slot_id = self.vnode_mapping[vnode.to_index()];
194
195 let list = self
196 .worker_slot_to_scan_range_mapping
197 .entry(worker_slot_id)
198 .or_default();
199 list.push((scan_range, vnode));
200
201 Ok(())
202 }
203
204 async fn build_executor(&mut self) -> Result<BoxedExecutor> {
207 self.next_stage_id += 1;
208 let mut sources = vec![];
209 for id in self.worker_slot_to_scan_range_mapping.keys() {
210 sources.push(self.build_prost_exchange_source(id)?);
211 }
212
213 if sources.is_empty() {
214 return Ok(Box::new(DummyExecutor {
215 schema: Schema::default(),
216 }));
217 }
218
219 let exchange_node = NodeBody::Exchange(ExchangeNode {
220 sources,
221 sequential: true,
222 input_schema: self.inner_side_schema.to_prost(),
223 });
224
225 let plan_node = PlanNode {
226 children: vec![],
227 identity: "LocalLookupJoinExchangeExecutor".to_owned(),
228 node_body: Some(exchange_node),
229 };
230
231 let task_id = self.task_id.clone();
232
233 let executor_builder = ExecutorBuilder::new(
234 &plan_node,
235 &task_id,
236 self.context.clone(),
237 self.shutdown_rx.clone(),
238 );
239
240 executor_builder.build().await
241 }
242}
243
244pub struct LocalLookupJoinExecutor<K, B: LookupExecutorBuilder> {
256 base: LookupJoinBase<K, B>,
257}
258
259impl<K: HashKey, B: LookupExecutorBuilder> Executor for LocalLookupJoinExecutor<K, B> {
260 fn schema(&self) -> &Schema {
261 &self.base.schema
262 }
263
264 fn identity(&self) -> &str {
265 &self.base.identity
266 }
267
268 fn execute(self: Box<Self>) -> BoxedDataChunkStream {
269 Box::new(self.base).do_execute()
270 }
271}
272
273impl<K, B: LookupExecutorBuilder> LocalLookupJoinExecutor<K, B> {
274 pub fn new(base: LookupJoinBase<K, B>) -> Self {
275 Self { base }
276 }
277}
278
279pub struct LocalLookupJoinExecutorBuilder {}
280
281impl BoxedExecutorBuilder for LocalLookupJoinExecutorBuilder {
282 async fn new_boxed_executor(
283 source: &ExecutorBuilder<'_>,
284 inputs: Vec<BoxedExecutor>,
285 ) -> Result<BoxedExecutor> {
286 let [outer_side_input]: [_; 1] = inputs.try_into().unwrap();
287
288 let lookup_join_node = try_match_expand!(
289 source.plan_node().get_node_body().unwrap(),
290 NodeBody::LocalLookupJoin
291 )?;
292
293 let join_type = JoinType::from_prost(lookup_join_node.get_join_type()?);
294 let condition = match lookup_join_node.get_condition() {
295 Ok(cond_prost) => Some(build_from_prost(cond_prost)?),
296 Err(_) => None,
297 };
298
299 let output_indices: Vec<usize> = lookup_join_node
300 .get_output_indices()
301 .iter()
302 .map(|&x| x as usize)
303 .collect();
304
305 let outer_side_data_types = outer_side_input.schema().data_types();
306
307 let table_desc = lookup_join_node.get_inner_side_table_desc()?;
308 let inner_side_column_ids = lookup_join_node.get_inner_side_column_ids().clone();
309
310 let inner_side_schema = Schema {
311 fields: inner_side_column_ids
312 .iter()
313 .map(|&id| {
314 let column = table_desc
315 .columns
316 .iter()
317 .find(|c| c.column_id == id)
318 .unwrap();
319 Field::from(&ColumnDesc::from(column))
320 })
321 .collect_vec(),
322 };
323
324 let fields = if join_type == JoinType::LeftSemi || join_type == JoinType::LeftAnti {
325 outer_side_input.schema().fields.clone()
326 } else {
327 [
328 outer_side_input.schema().fields.clone(),
329 inner_side_schema.fields.clone(),
330 ]
331 .concat()
332 };
333
334 let original_schema = Schema { fields };
335 let actual_schema = output_indices
336 .iter()
337 .map(|&idx| original_schema[idx].clone())
338 .collect();
339
340 let mut outer_side_key_idxs = vec![];
341 for outer_side_key in lookup_join_node.get_outer_side_key() {
342 outer_side_key_idxs.push(*outer_side_key as usize)
343 }
344
345 let outer_side_key_types: Vec<DataType> = outer_side_key_idxs
346 .iter()
347 .map(|&i| outer_side_data_types[i].clone())
348 .collect_vec();
349
350 let lookup_prefix_len: usize = lookup_join_node.get_lookup_prefix_len() as usize;
351
352 let mut inner_side_key_idxs = vec![];
353 for inner_side_key in lookup_join_node.get_inner_side_key() {
354 inner_side_key_idxs.push(*inner_side_key as usize)
355 }
356
357 let inner_side_key_types = inner_side_key_idxs
358 .iter()
359 .map(|&i| inner_side_schema.fields[i].data_type.clone())
360 .collect_vec();
361
362 let null_safe = lookup_join_node.get_null_safe().clone();
363
364 let vnode_mapping = lookup_join_node
365 .get_inner_side_vnode_mapping()
366 .iter()
367 .copied()
368 .map(WorkerSlotId::from)
369 .collect_vec();
370
371 assert!(!vnode_mapping.is_empty());
372
373 let chunk_size = source.context().get_config().developer.chunk_size;
374
375 let asof_desc = lookup_join_node
376 .asof_desc
377 .map(|desc| AsOfDesc::from_protobuf(&desc))
378 .transpose()?;
379
380 let worker_nodes = lookup_join_node.get_worker_nodes();
381 let worker_slot_mapping: HashMap<WorkerSlotId, WorkerNode> = worker_nodes
382 .iter()
383 .flat_map(|worker| {
384 (0..(worker.compute_node_parallelism()))
385 .map(|i| (WorkerSlotId::new(worker.id, i), worker.clone()))
386 })
387 .collect();
388
389 let vnodes = Some(Bitmap::ones(table_desc.vnode_count()).into());
390
391 let inner_side_builder = InnerSideExecutorBuilder {
392 table_desc: table_desc.clone(),
393 table_distribution: TableDistribution::new_from_storage_table_desc(vnodes, table_desc),
394 vnode_mapping,
395 outer_side_key_types,
396 inner_side_schema,
397 inner_side_column_ids,
398 inner_side_key_types: inner_side_key_types.clone(),
399 lookup_prefix_len,
400 context: source.context().clone(),
401 task_id: source.task_id.clone(),
402 query_epoch: lookup_join_node
403 .query_epoch
404 .ok_or_else(|| anyhow!("query_epoch not set in local lookup join"))?,
405 worker_slot_to_scan_range_mapping: HashMap::new(),
406 chunk_size,
407 shutdown_rx: source.shutdown_rx().clone(),
408 next_stage_id: 0,
409 worker_slot_mapping,
410 };
411
412 let identity = source.plan_node().get_identity().clone();
413 Ok(LocalLookupJoinExecutorArgs {
414 join_type,
415 condition,
416 outer_side_input,
417 outer_side_data_types,
418 outer_side_key_idxs,
419 inner_side_builder,
420 inner_side_key_types,
421 inner_side_key_idxs,
422 null_safe,
423 lookup_prefix_len,
424 chunk_builder: DataChunkBuilder::new(original_schema.data_types(), chunk_size),
425 schema: actual_schema,
426 output_indices,
427 chunk_size,
428 asof_desc,
429 identity: identity.clone(),
430 shutdown_rx: source.shutdown_rx().clone(),
431 mem_ctx: source.context().create_executor_mem_context(&identity),
432 }
433 .dispatch())
434 }
435}
436
437struct LocalLookupJoinExecutorArgs<B: LookupExecutorBuilder> {
438 join_type: JoinType,
439 condition: Option<BoxedExpression>,
440 outer_side_input: BoxedExecutor,
441 outer_side_data_types: Vec<DataType>,
442 outer_side_key_idxs: Vec<usize>,
443 inner_side_builder: B,
444 inner_side_key_types: Vec<DataType>,
445 inner_side_key_idxs: Vec<usize>,
446 null_safe: Vec<bool>,
447 lookup_prefix_len: usize,
448 chunk_builder: DataChunkBuilder,
449 schema: Schema,
450 output_indices: Vec<usize>,
451 chunk_size: usize,
452 asof_desc: Option<AsOfDesc>,
453 identity: String,
454 shutdown_rx: ShutdownToken,
455 mem_ctx: MemoryContext,
456}
457
458impl<B: LookupExecutorBuilder> HashKeyDispatcher for LocalLookupJoinExecutorArgs<B> {
459 type Output = BoxedExecutor;
460
461 fn dispatch_impl<K: HashKey>(self) -> Self::Output {
462 Box::new(LocalLookupJoinExecutor::<K, B>::new(LookupJoinBase {
463 join_type: self.join_type,
464 condition: self.condition,
465 outer_side_input: self.outer_side_input,
466 outer_side_data_types: self.outer_side_data_types,
467 outer_side_key_idxs: self.outer_side_key_idxs,
468 inner_side_builder: self.inner_side_builder,
469 inner_side_key_types: self.inner_side_key_types,
470 inner_side_key_idxs: self.inner_side_key_idxs,
471 null_safe: self.null_safe,
472 lookup_prefix_len: self.lookup_prefix_len,
473 chunk_builder: self.chunk_builder,
474 schema: self.schema,
475 output_indices: self.output_indices,
476 chunk_size: self.chunk_size,
477 asof_desc: self.asof_desc,
478 identity: self.identity,
479 shutdown_rx: self.shutdown_rx,
480 mem_ctx: self.mem_ctx,
481 _phantom: PhantomData,
482 }))
483 }
484
485 fn data_types(&self) -> &[DataType] {
486 &self.inner_side_key_types
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use std::sync::Arc;
493
494 use risingwave_common::array::{DataChunk, DataChunkTestExt};
495 use risingwave_common::catalog::{Field, Schema};
496 use risingwave_common::hash::HashKeyDispatcher;
497 use risingwave_common::memory::MemoryContext;
498 use risingwave_common::types::DataType;
499 use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
500 use risingwave_common::util::sort_util::{ColumnOrder, OrderType};
501 use risingwave_expr::expr::{BoxedExpression, build_from_pretty};
502
503 use crate::executor::join::JoinType;
504 use crate::executor::test_utils::{
505 FakeInnerSideExecutorBuilder, MockExecutor, diff_executor_output,
506 };
507 use crate::executor::{BoxedExecutor, SortExecutor};
508 use crate::local_lookup_join::LocalLookupJoinExecutorArgs;
509 use crate::monitor::BatchSpillMetrics;
510 use crate::task::ShutdownToken;
511
512 const CHUNK_SIZE: usize = 1024;
513
514 fn create_outer_side_input() -> BoxedExecutor {
515 let schema = Schema {
516 fields: vec![
517 Field::unnamed(DataType::Int32),
518 Field::unnamed(DataType::Float32),
519 ],
520 };
521 let mut executor = MockExecutor::new(schema);
522
523 executor.add(DataChunk::from_pretty(
524 "i f
525 1 6.1
526 2 8.4
527 3 3.9",
528 ));
529
530 executor.add(DataChunk::from_pretty(
531 "i f
532 2 5.5
533 5 4.1
534 5 9.1
535 . .",
536 ));
537
538 Box::new(executor)
539 }
540
541 fn create_lookup_join_executor(
542 join_type: JoinType,
543 condition: Option<BoxedExpression>,
544 null_safe: bool,
545 ) -> BoxedExecutor {
546 let outer_side_input = create_outer_side_input();
547
548 let fields = if join_type == JoinType::LeftSemi || join_type == JoinType::LeftAnti {
549 outer_side_input.schema().fields.clone()
550 } else {
551 [
552 outer_side_input.schema().fields.clone(),
553 outer_side_input.schema().fields.clone(),
554 ]
555 .concat()
556 };
557 let original_schema = Schema { fields };
558
559 let inner_side_schema = Schema {
560 fields: outer_side_input.schema().fields.clone(),
561 };
562
563 let inner_side_data_types = inner_side_schema.data_types();
564 let outer_side_data_types = outer_side_input.schema().data_types();
565
566 LocalLookupJoinExecutorArgs {
567 join_type,
568 condition,
569 outer_side_input,
570 outer_side_data_types,
571 outer_side_key_idxs: vec![0],
572 inner_side_builder: FakeInnerSideExecutorBuilder::new(inner_side_schema),
573 inner_side_key_types: vec![inner_side_data_types[0].clone()],
574 inner_side_key_idxs: vec![0],
575 null_safe: vec![null_safe],
576 lookup_prefix_len: 1,
577 chunk_builder: DataChunkBuilder::new(original_schema.data_types(), CHUNK_SIZE),
578 schema: original_schema.clone(),
579 output_indices: (0..original_schema.len()).collect(),
580 chunk_size: CHUNK_SIZE,
581 asof_desc: None,
582 identity: "TestLookupJoinExecutor".to_owned(),
583 shutdown_rx: ShutdownToken::empty(),
584 mem_ctx: MemoryContext::none(),
585 }
586 .dispatch()
587 }
588
589 fn create_order_by_executor(child: BoxedExecutor) -> BoxedExecutor {
590 let column_orders = vec![
591 ColumnOrder {
592 column_index: 0,
593 order_type: OrderType::ascending(),
594 },
595 ColumnOrder {
596 column_index: 1,
597 order_type: OrderType::ascending(),
598 },
599 ];
600
601 Box::new(SortExecutor::new(
602 child,
603 Arc::new(column_orders),
604 "SortExecutor".into(),
605 CHUNK_SIZE,
606 MemoryContext::none(),
607 None,
608 BatchSpillMetrics::for_test(),
609 ))
610 }
611
612 async fn do_test(
613 join_type: JoinType,
614 condition: Option<BoxedExpression>,
615 null_safe: bool,
616 expected: DataChunk,
617 ) {
618 let lookup_join_executor = create_lookup_join_executor(join_type, condition, null_safe);
619 let order_by_executor = create_order_by_executor(lookup_join_executor);
620 let mut expected_mock_exec = MockExecutor::new(order_by_executor.schema().clone());
621 expected_mock_exec.add(expected);
622 diff_executor_output(order_by_executor, Box::new(expected_mock_exec)).await;
623 }
624
625 #[tokio::test]
626 async fn test_inner_join() {
627 let expected = DataChunk::from_pretty(
628 "i f i f
629 1 6.1 1 9.2
630 2 5.5 2 5.5
631 2 5.5 2 4.4
632 2 8.4 2 5.5
633 2 8.4 2 4.4
634 5 4.1 5 2.3
635 5 4.1 5 3.7
636 5 9.1 5 2.3
637 5 9.1 5 3.7",
638 );
639
640 do_test(JoinType::Inner, None, false, expected).await;
641 }
642
643 #[tokio::test]
644 async fn test_null_safe_inner_join() {
645 let expected = DataChunk::from_pretty(
646 "i f i f
647 1 6.1 1 9.2
648 2 5.5 2 5.5
649 2 5.5 2 4.4
650 2 8.4 2 5.5
651 2 8.4 2 4.4
652 5 4.1 5 2.3
653 5 4.1 5 3.7
654 5 9.1 5 2.3
655 5 9.1 5 3.7
656 . . . .",
657 );
658
659 do_test(JoinType::Inner, None, true, expected).await;
660 }
661
662 #[tokio::test]
663 async fn test_left_outer_join() {
664 let expected = DataChunk::from_pretty(
665 "i f i f
666 1 6.1 1 9.2
667 2 5.5 2 5.5
668 2 5.5 2 4.4
669 2 8.4 2 5.5
670 2 8.4 2 4.4
671 3 3.9 . .
672 5 4.1 5 2.3
673 5 4.1 5 3.7
674 5 9.1 5 2.3
675 5 9.1 5 3.7
676 . . . .",
677 );
678
679 do_test(JoinType::LeftOuter, None, false, expected).await;
680 }
681
682 #[tokio::test]
683 async fn test_left_semi_join() {
684 let expected = DataChunk::from_pretty(
685 "i f
686 1 6.1
687 2 5.5
688 2 8.4
689 5 4.1
690 5 9.1",
691 );
692
693 do_test(JoinType::LeftSemi, None, false, expected).await;
694 }
695
696 #[tokio::test]
697 async fn test_left_anti_join() {
698 let expected = DataChunk::from_pretty(
699 "i f
700 3 3.9
701 . .",
702 );
703
704 do_test(JoinType::LeftAnti, None, false, expected).await;
705 }
706
707 #[tokio::test]
708 async fn test_inner_join_with_condition() {
709 let expected = DataChunk::from_pretty(
710 "i f i f
711 1 6.1 1 9.2
712 2 5.5 2 5.5
713 2 8.4 2 5.5",
714 );
715 let condition = build_from_pretty("(less_than:boolean 5:float4 $3:float4)");
716
717 do_test(JoinType::Inner, Some(condition), false, expected).await;
718 }
719
720 #[tokio::test]
721 async fn test_left_outer_join_with_condition() {
722 let expected = DataChunk::from_pretty(
723 "i f i f
724 1 6.1 1 9.2
725 2 5.5 2 5.5
726 2 8.4 2 5.5
727 3 3.9 . .
728 5 4.1 . .
729 5 9.1 . .
730 . . . .",
731 );
732 let condition = build_from_pretty("(less_than:boolean 5:float4 $3:float4)");
733
734 do_test(JoinType::LeftOuter, Some(condition), false, expected).await;
735 }
736
737 #[tokio::test]
738 async fn test_left_semi_join_with_condition() {
739 let expected = DataChunk::from_pretty(
740 "i f
741 1 6.1
742 2 5.5
743 2 8.4",
744 );
745 let condition = build_from_pretty("(less_than:boolean 5:float4 $3:float4)");
746
747 do_test(JoinType::LeftSemi, Some(condition), false, expected).await;
748 }
749
750 #[tokio::test]
751 async fn test_left_anti_join_with_condition() {
752 let expected = DataChunk::from_pretty(
753 "i f
754 3 3.9
755 5 4.1
756 5 9.1
757 . .",
758 );
759 let condition = build_from_pretty("(less_than:boolean 5:float4 $3:float4)");
760
761 do_test(JoinType::LeftAnti, Some(condition), false, expected).await;
762 }
763}