radixdb_executor/subquery/semi_join.rs
1use super::*;
2
3impl<'host, H: SubqueryHost + ?Sized> SubqueryExecutor<'host, H> {
4 /// Check if index-nested-loop would be more efficient than semi-join for EXISTS.
5 ///
6 /// Returns true if:
7 /// 1. There's a small LIMIT (< 500)
8 /// 2. Inner table has index on correlation column
9 ///
10 /// With small LIMIT and early termination at the outer level, per-row EXISTS
11 /// evaluation is faster because:
12 /// - O(LIMIT × log(inner_size)) for index probe vs O(inner_size) for hash build
13 /// - Example: LIMIT 100, inner=30K → 100×15=1500 ops vs 30000 ops
14 ///
15 /// For EXISTS with additional predicate (e.g., EXISTS ... WHERE o.user_id = u.id AND o.amount > 500):
16 /// - Index lookup gets candidate row_ids for correlation
17 /// - Rows are fetched in batches and predicate is evaluated with early exit
18 /// - This is O(LIMIT × avg_rows_per_key × predicate_selectivity) which is still efficient
19 pub(super) fn should_use_index_nested_loop(
20 &self,
21 info: &SemiJoinInfo,
22 outer_limit: Option<i64>,
23 ) -> bool {
24 // Use index-nested-loop for small LIMIT queries WITHOUT additional predicates.
25 //
26 // IMPORTANT: If there's a non-correlated predicate (e.g., status = 'cancelled'),
27 // semi-join is FASTER because:
28 // 1. Semi-join executes the filtered inner query ONCE, builds a hash set
29 // 2. Index NL would probe the index for EACH outer row, then filter
30 //
31 // Benchmark shows semi-join is 3x faster when additional predicates exist:
32 // - Semi-join: ~290μs (execute filtered query once, O(1) hash lookups)
33 // - Index NL: ~940μs (per-row index probe + filter)
34 //
35 // Only use Index NL when:
36 // 1. Small LIMIT (early termination benefit)
37 // 2. NO additional predicates (pure correlation only)
38 // 3. Index exists on correlation column
39 const SMALL_LIMIT_THRESHOLD: i64 = 500;
40
41 // If there's a non-correlated predicate, always use semi-join
42 // The semi-join can efficiently filter by predicate in bulk
43 if info.non_correlated_where.is_some() {
44 return false;
45 }
46
47 // For pure correlation (no additional predicate), check if index NL is worth it
48 if let Some(limit) = outer_limit {
49 if limit > 0 && limit <= SMALL_LIMIT_THRESHOLD {
50 // Check if inner table has an index on correlation column
51 // Without index, per-row evaluation would be slow
52 let table = match self.host.subquery_open_table(&info.inner_table) {
53 Ok(handle) => handle,
54 Err(_) => return false,
55 };
56
57 // Check for index on correlation column
58 if table
59 .table
60 .get_index_on_column(&info.inner_column)
61 .is_some()
62 {
63 return true;
64 }
65 }
66 }
67
68 // For larger queries or no index, use semi-join
69 false
70 }
71
72 /// Check if index-nested-loop should be preferred over anti-join for NOT EXISTS.
73 ///
74 /// For NOT EXISTS, anti-join using HashJoinOperator is almost always more efficient
75 /// than both index-nested-loop and InHashSet because:
76 /// 1. HashJoinOperator does bulk hash table build/probe (cache-efficient)
77 /// 2. No per-row expression evaluation overhead
78 /// 3. Even with LIMIT, the bulk operation is faster than per-row checking
79 ///
80 /// The only case where we might prefer index-nested-loop is for VERY small LIMIT
81 /// (e.g., LIMIT 10) with a highly selective index, but benchmarks show hash join
82 /// is still faster in most cases.
83 pub fn should_use_index_nested_loop_for_anti_join(
84 &self,
85 _info: &SemiJoinInfo,
86 outer_limit: Option<i64>,
87 ) -> bool {
88 // For very small LIMIT (<= 10), index-nested-loop might be faster
89 // because it can terminate very early
90 if let Some(limit) = outer_limit {
91 if limit <= 10 {
92 return true;
93 }
94 }
95 // For all other cases, prefer anti-join for NOT EXISTS
96 false
97 }
98
99 /// Execute the semi-join optimization for an EXISTS subquery.
100 ///
101 /// Instead of executing the subquery for each outer row, we:
102 /// 1. Execute the inner query once with non-correlated predicates
103 /// 2. Collect all distinct values of the inner correlation column
104 /// 3. Return an FxHashSet for fast O(1) lookups
105 ///
106 /// Results are cached to avoid re-execution for the same query within a single
107 /// top-level query execution.
108 pub fn execute_semi_join_optimization(
109 &self,
110 info: &SemiJoinInfo,
111 ctx: &ExecutionContext,
112 ) -> Result<CompactArc<ValueSet>> {
113 // Build cache key hash from inner table, column, and WHERE predicate hash
114 // Uses u64 hash to avoid any string allocation
115 let pred_hash = info
116 .non_correlated_where
117 .as_ref()
118 .map(|arc| compute_expression_hash(arc.as_ref()))
119 .unwrap_or(0);
120 let cache_key =
121 compute_semi_join_cache_key(&info.inner_table, &info.inner_column, pred_hash);
122
123 // Check cache first - return Arc directly (no clone needed)
124 if let Some(cached) = get_cached_semi_join(cache_key) {
125 return Ok(cached);
126 }
127
128 // Build SELECT inner_column FROM inner_table WHERE non_correlated_predicates
129 // Use dummy_token_clone() to avoid allocations - token literal is not used during execution
130 let inner_col_expr = Expression::Identifier(Identifier::new(
131 dummy_token_clone(),
132 info.inner_column.clone(),
133 ));
134
135 let table_source = Expression::TableSource(Box::new(SimpleTableSource {
136 token: dummy_token_clone(),
137 name: Identifier::new(dummy_token_clone(), info.inner_table.clone()),
138 alias: info
139 .inner_alias
140 .as_ref()
141 .map(|a| Identifier::new(dummy_token_clone(), a.clone())),
142 as_of: None,
143 }));
144
145 let select_stmt = SelectStatement {
146 token: dummy_token_clone(),
147 // Don't use DISTINCT here - it's slower in RadixDB because it requires
148 // additional hashing/sorting overhead. Instead, we collect into HashSet
149 // which deduplicates more efficiently for this use case.
150 distinct: false,
151 distinct_on: vec![],
152 columns: vec![inner_col_expr],
153 with: None,
154 table_expr: Some(Box::new(table_source)),
155 where_clause: info
156 .non_correlated_where
157 .as_ref()
158 .map(|arc| Box::new(arc.as_ref().clone())),
159 group_by: GroupByClause {
160 columns: vec![],
161 modifier: GroupByModifier::None,
162 },
163 having: None,
164 window_defs: vec![],
165 order_by: vec![],
166 limit: None,
167 offset: None,
168 set_operations: vec![],
169 };
170
171 // Execute the query with incremented depth to avoid creating new TimeoutGuard
172 let subquery_ctx = ctx.with_incremented_query_depth();
173 let mut result = self
174 .host
175 .subquery_execute_select(&select_stmt, &subquery_ctx)?;
176
177 // Collect values into Vec first (faster than direct FxHashSet insertion),
178 // then convert to FxHashSet for deduplication and O(1) lookups
179 let mut values_vec = Vec::with_capacity(10_000);
180 while result.next() {
181 let row = result.row();
182 if let Some(value) = row.get(0) {
183 if !value.is_null() {
184 values_vec.push(value.clone());
185 }
186 }
187 }
188 if let Some(err) = result.last_error() {
189 return Err(err);
190 }
191 // Build FxHashSet from Vec - this deduplicates automatically
192 let hash_set: ValueSet = values_vec.into_iter().collect();
193
194 // Wrap in CompactArc once - no cloning needed
195 let hash_set_arc = CompactArc::new(hash_set);
196
197 // Cache for subsequent calls within this query (CompactArc clone is cheap)
198 cache_semi_join_arc(
199 cache_key,
200 &info.inner_table,
201 CompactArc::clone(&hash_set_arc),
202 );
203
204 Ok(hash_set_arc)
205 }
206
207 /// Execute NOT EXISTS as a true anti-join using HashJoinOperator.
208 ///
209 /// This is more efficient than the InHashSet approach because:
210 /// 1. HashJoinOperator builds hash table once and probes in bulk
211 /// 2. No per-row expression evaluation overhead
212 /// 3. Better cache efficiency due to batch processing
213 /// 4. Direct table access without going through full query pipeline
214 ///
215 /// # Arguments
216 /// * `info` - SemiJoinInfo extracted from the NOT EXISTS subquery
217 /// * `outer_rows` - Pre-materialized outer table rows
218 /// * `outer_columns` - Column names for outer table
219 /// * `_ctx` - Execution context (not used but kept for API consistency)
220 ///
221 /// # Returns
222 /// Rows from outer table that have NO match in inner table (anti-join result)
223 pub fn execute_anti_join(
224 &self,
225 info: &SemiJoinInfo,
226 outer_rows: CompactArc<Vec<radixdb_core::Row>>,
227 outer_columns: &[String],
228 _ctx: &ExecutionContext,
229 ) -> Result<radixdb_core::RowVec> {
230 // Direct table access - much faster than going through execute_select
231 let inner_handle = self.host.subquery_open_table(&info.inner_table)?;
232 let inner_table = &inner_handle.table;
233
234 // Convert non-correlated WHERE to storage expression for pushdown
235 let storage_expr = info
236 .non_correlated_where
237 .as_ref()
238 .and_then(|arc| convert_ast_to_storage_expr(arc.as_ref()));
239
240 // Find the inner column index for join key extraction
241 // Use schema's cached lowercase column names to avoid computing to_lowercase()
242 let inner_schema = inner_table.schema();
243 let inner_columns = inner_schema.column_names_arc();
244 let inner_columns_lower = inner_schema.column_names_lower_arc();
245
246 let inner_key_source_idx = {
247 let search_col = info.inner_column.to_lowercase();
248 inner_columns_lower
249 .iter()
250 .position(|c| c == &search_col)
251 .ok_or_else(|| {
252 Error::internal(format!(
253 "Anti-join inner key column '{}' not found in table columns: {:?}",
254 info.inner_column, inner_columns
255 ))
256 })?
257 };
258
259 // Extract only the join key values (deduplicated) and convert to single-column rows.
260 // The anti-join build side never needs the rest of the inner row, so keep
261 // the scanner projection at the key column boundary instead of
262 // materializing `collect_all_rows()`.
263 let mut inner_key_scanner = inner_table.scan(
264 &[inner_key_source_idx],
265 storage_expr.as_ref().map(|e| e.as_ref()),
266 )?;
267
268 // Use a HashSet for deduplication to minimize the build side
269 // Cap initial capacity to avoid over-allocation when many rows have few unique keys
270 let estimated_unique = inner_key_scanner
271 .estimated_count()
272 .unwrap_or(10000)
273 .min(10000);
274 let mut seen: ValueSet = ValueSet::with_capacity(estimated_unique);
275 let mut inner_rows: Vec<radixdb_core::Row> = Vec::with_capacity(estimated_unique);
276
277 while inner_key_scanner.next() {
278 if let Some(value) = inner_key_scanner.row().get(0) {
279 if !value.is_null() {
280 // Clone once and reuse for both HashSet and Row to avoid double allocation
281 let cloned = value.clone();
282 if seen.insert(cloned.clone()) {
283 inner_rows.push(radixdb_core::Row::from_values(vec![cloned]));
284 }
285 }
286 }
287 }
288 if let Some(err) = inner_key_scanner.err() {
289 return Err(err.clone());
290 }
291 inner_key_scanner.close()?;
292
293 // Find the outer column index for join key
294 // OPTIMIZATION: Pre-compute lowercase column names once to avoid per-column to_lowercase()
295 let outer_columns_lower: Vec<String> =
296 outer_columns.iter().map(|c| c.to_lowercase()).collect();
297
298 let outer_key_idx = {
299 let search_col = info.outer_column.to_lowercase();
300 let search_suffix = format!(".{}", search_col); // Pre-compute once outside loop
301 outer_columns_lower
302 .iter()
303 .position(|c| {
304 c == &search_col
305 || c.ends_with(&search_suffix)
306 || c.split('.').next_back() == Some(search_col.as_str())
307 })
308 .ok_or_else(|| {
309 Error::internal(format!(
310 "Anti-join outer key column '{}' not found in columns: {:?}",
311 info.outer_column, outer_columns
312 ))
313 })?
314 };
315
316 // Inner column is always index 0 (we extracted only the join key)
317 let inner_key_idx = 0;
318
319 // Create schemas for operators
320 let outer_schema: Vec<ColumnInfo> = outer_columns.iter().map(ColumnInfo::new).collect();
321 let inner_schema = vec![ColumnInfo::new(&info.inner_column)];
322
323 // Create MaterializedOperators
324 let outer_op = Box::new(MaterializedOperator::from_arc(
325 outer_rows,
326 outer_schema.clone(),
327 ));
328 let inner_op = Box::new(MaterializedOperator::new(inner_rows, inner_schema));
329
330 // Create anti-join operator
331 // Anti-join: return outer rows that have NO match in inner
332 let mut join_op = HashJoinOperator::new(
333 outer_op,
334 inner_op,
335 JoinType::Anti,
336 vec![outer_key_idx],
337 vec![inner_key_idx],
338 JoinSide::Right, // Build on smaller (inner) side
339 );
340
341 // Execute the join with synthetic row IDs
342 if let Err(error) = join_op.open() {
343 let _ = join_op.close();
344 return Err(error);
345 }
346 let execution_result = (|| {
347 let mut result_rows = radixdb_core::RowVec::new();
348 let mut row_id = 0i64;
349 while let Some(row_ref) = join_op.next()? {
350 result_rows.push((row_id, row_ref.into_owned()));
351 row_id += 1;
352 }
353 Ok(result_rows)
354 })();
355 let close_result = join_op.close();
356 let result_rows = match (execution_result, close_result) {
357 (Ok(rows), Ok(())) => rows,
358 (Err(error), _) | (Ok(_), Err(error)) => return Err(error),
359 };
360
361 Ok(result_rows)
362 }
363
364 /// Try to extract SemiJoinInfo from a NOT EXISTS expression.
365 /// Returns None if the expression is not a valid NOT EXISTS pattern.
366 pub fn try_extract_not_exists_info(
367 expr: &Expression,
368 outer_tables: &[String],
369 ) -> Option<SemiJoinInfo> {
370 if let Expression::Prefix(prefix) = expr {
371 if prefix.operator.eq_ignore_ascii_case("NOT") {
372 if let Expression::Exists(exists) = prefix.right.as_ref() {
373 return Self::try_extract_semi_join_info(exists, true, outer_tables);
374 }
375 }
376 }
377 None
378 }
379
380 /// Transform a WHERE clause with EXISTS into one using a pre-computed hash set.
381 ///
382 /// Replaces: EXISTS (SELECT ...) with: outer_col IN (hash_set_values)
383 pub fn transform_exists_to_in_list(
384 info: &SemiJoinInfo,
385 hash_set: CompactArc<ValueSet>,
386 ) -> Expression {
387 // For empty hash set, return FALSE (no matches exist)
388 // For NOT EXISTS with empty set, return TRUE (nothing exists to negate)
389 if hash_set.is_empty() {
390 return Expression::BooleanLiteral(BooleanLiteral {
391 token: dummy_token_clone(),
392 value: info.is_negated,
393 });
394 }
395
396 // Build the outer column expression using dummy_token_clone() to avoid allocations
397 let outer_col_expr = if let Some(ref tbl) = info.outer_table {
398 Expression::QualifiedIdentifier(QualifiedIdentifier {
399 token: dummy_token_clone(),
400 qualifier: Box::new(Identifier::new(dummy_token_clone(), tbl.clone())),
401 intermediate: None,
402 name: Box::new(Identifier::new(
403 dummy_token_clone(),
404 info.outer_column.clone(),
405 )),
406 })
407 } else {
408 Expression::Identifier(Identifier::new(
409 dummy_token_clone(),
410 info.outer_column.clone(),
411 ))
412 };
413
414 // Use InHashSet with Arc for O(1) lookup and cheap cloning in parallel execution
415 Expression::InHashSet(InHashSetExpression {
416 token: dummy_token_clone(),
417 column: Box::new(outer_col_expr),
418 values: hash_set, // Already Arc, no wrapping needed
419 not: info.is_negated,
420 })
421 }
422
423 /// Try to optimize correlated EXISTS subqueries to semi-join.
424 /// Returns Some(optimized_expression) if successful, None if not applicable.
425 ///
426 /// Note: This function now checks if index-nested-loop would be more efficient
427 /// and skips the semi-join transformation in that case, allowing per-row index probing.
428 ///
429 /// The `outer_limit` parameter helps decide between strategies:
430 /// - With small LIMIT + index: prefer index-nested-loop (per-row probing with early termination)
431 /// - Without LIMIT: prefer semi-join (scan inner once, hash lookup per outer row)
432 pub fn try_optimize_exists_to_semi_join(
433 &self,
434 expr: &Expression,
435 ctx: &ExecutionContext,
436 outer_tables: &[String],
437 outer_limit: Option<i64>,
438 ) -> Result<Option<Expression>> {
439 match expr {
440 Expression::Exists(exists) => {
441 if let Some(info) = Self::try_extract_semi_join_info(exists, false, outer_tables) {
442 // Check if index-nested-loop would be more efficient
443 // (index exists + no additional predicates, OR index exists + small LIMIT)
444 if self.should_use_index_nested_loop(&info, outer_limit) {
445 return Ok(None); // Skip semi-join, use index probing per row
446 }
447 // Semi-join optimization: execute inner query once, collect into hash set
448 // This enables InHashSet optimization to probe outer table's PK directly
449 let hash_set = self.execute_semi_join_optimization(&info, ctx)?;
450 return Ok(Some(Self::transform_exists_to_in_list(&info, hash_set)));
451 }
452 Ok(None)
453 }
454
455 Expression::Prefix(prefix) if prefix.operator.eq_ignore_ascii_case("NOT") => {
456 if let Expression::Exists(exists) = prefix.right.as_ref() {
457 if let Some(info) = Self::try_extract_semi_join_info(exists, true, outer_tables)
458 {
459 // Check if index-nested-loop would be more efficient
460 if self.should_use_index_nested_loop(&info, outer_limit) {
461 return Ok(None); // Skip semi-join, use index probing per row
462 }
463 let hash_set = self.execute_semi_join_optimization(&info, ctx)?;
464 return Ok(Some(Self::transform_exists_to_in_list(&info, hash_set)));
465 }
466 }
467 Ok(None)
468 }
469
470 Expression::Infix(infix) if infix.operator.eq_ignore_ascii_case("AND") => {
471 // Try to optimize EXISTS in either branch of AND
472 let left_opt = self.try_optimize_exists_to_semi_join(
473 &infix.left,
474 ctx,
475 outer_tables,
476 outer_limit,
477 )?;
478 let right_opt = self.try_optimize_exists_to_semi_join(
479 &infix.right,
480 ctx,
481 outer_tables,
482 outer_limit,
483 )?;
484
485 match (left_opt, right_opt) {
486 (Some(new_left), Some(new_right)) => {
487 Ok(Some(Expression::Infix(InfixExpression {
488 token: dummy_token_clone(),
489 left: Box::new(new_left),
490 operator: "AND".into(),
491 op_type: InfixOperator::And,
492 right: Box::new(new_right),
493 })))
494 }
495 (Some(new_left), None) => Ok(Some(Expression::Infix(InfixExpression {
496 token: dummy_token_clone(),
497 left: Box::new(new_left),
498 operator: "AND".into(),
499 op_type: InfixOperator::And,
500 right: infix.right.clone(),
501 }))),
502 (None, Some(new_right)) => Ok(Some(Expression::Infix(InfixExpression {
503 token: dummy_token_clone(),
504 left: infix.left.clone(),
505 operator: "AND".into(),
506 op_type: InfixOperator::And,
507 right: Box::new(new_right),
508 }))),
509 (None, None) => Ok(None),
510 }
511 }
512
513 Expression::Infix(infix) if infix.operator.eq_ignore_ascii_case("OR") => {
514 // For OR, both branches must be optimizable for benefit
515 // But we can still optimize individual EXISTS clauses
516 let left_opt = self.try_optimize_exists_to_semi_join(
517 &infix.left,
518 ctx,
519 outer_tables,
520 outer_limit,
521 )?;
522 let right_opt = self.try_optimize_exists_to_semi_join(
523 &infix.right,
524 ctx,
525 outer_tables,
526 outer_limit,
527 )?;
528
529 match (left_opt, right_opt) {
530 (Some(new_left), Some(new_right)) => {
531 Ok(Some(Expression::Infix(InfixExpression {
532 token: dummy_token_clone(),
533 left: Box::new(new_left),
534 operator: "OR".into(),
535 op_type: InfixOperator::Or,
536 right: Box::new(new_right),
537 })))
538 }
539 (Some(new_left), None) => Ok(Some(Expression::Infix(InfixExpression {
540 token: dummy_token_clone(),
541 left: Box::new(new_left),
542 operator: "OR".into(),
543 op_type: InfixOperator::Or,
544 right: infix.right.clone(),
545 }))),
546 (None, Some(new_right)) => Ok(Some(Expression::Infix(InfixExpression {
547 token: dummy_token_clone(),
548 left: infix.left.clone(),
549 operator: "OR".into(),
550 op_type: InfixOperator::Or,
551 right: Box::new(new_right),
552 }))),
553 (None, None) => Ok(None),
554 }
555 }
556
557 _ => Ok(None),
558 }
559 }
560
561 // ============================================================================
562 // IN Subquery Semi-Join Optimization
563 // ============================================================================
564
565 /// Try to optimize IN subqueries to semi-join (execute once, hash lookup per row).
566 ///
567 /// This transforms:
568 /// ```sql
569 /// WHERE outer.col IN (SELECT inner_col FROM t WHERE non_correlated_pred)
570 /// ```
571 /// Into:
572 /// ```sql
573 /// WHERE outer.col IN (hash_set_of_inner_col_values)
574 /// ```
575 ///
576 /// # Optimization Criteria
577 ///
578 /// 1. IN right side must be a scalar subquery
579 /// 2. Subquery must SELECT exactly one column
580 /// 3. Subquery must have a simple table source (no joins)
581 /// 4. Subquery WHERE clause must NOT reference outer tables (non-correlated)
582 ///
583 /// # Performance Impact
584 ///
585 /// - **Before**: O(N×M) - executes subquery for each outer row
586 /// - **After**: O(N+M) - executes subquery once, O(1) hash lookup per row
587 pub fn try_optimize_in_to_semi_join(
588 &self,
589 expr: &Expression,
590 ctx: &ExecutionContext,
591 outer_tables: &[String],
592 ) -> Result<Option<Expression>> {
593 match expr {
594 Expression::In(in_expr) => {
595 // Check if right side is a scalar subquery
596 if let Expression::ScalarSubquery(subquery) = in_expr.right.as_ref() {
597 if let Some(info) = Self::try_extract_in_semi_join_info(
598 in_expr,
599 &subquery.subquery,
600 outer_tables,
601 ) {
602 // Execute subquery once and build hash set
603 let hash_set = self.execute_semi_join_optimization(&info, ctx)?;
604 return Ok(Some(Self::transform_exists_to_in_list(&info, hash_set)));
605 }
606 }
607 Ok(None)
608 }
609
610 Expression::Infix(infix) if infix.operator.eq_ignore_ascii_case("AND") => {
611 // Try to optimize IN in either branch of AND
612 let left_opt = self.try_optimize_in_to_semi_join(&infix.left, ctx, outer_tables)?;
613 let right_opt =
614 self.try_optimize_in_to_semi_join(&infix.right, ctx, outer_tables)?;
615
616 match (left_opt, right_opt) {
617 (Some(new_left), Some(new_right)) => {
618 Ok(Some(Expression::Infix(InfixExpression {
619 token: infix.token.clone(),
620 left: Box::new(new_left),
621 operator: infix.operator.clone(),
622 op_type: infix.op_type,
623 right: Box::new(new_right),
624 })))
625 }
626 (Some(new_left), None) => Ok(Some(Expression::Infix(InfixExpression {
627 token: infix.token.clone(),
628 left: Box::new(new_left),
629 operator: infix.operator.clone(),
630 op_type: infix.op_type,
631 right: infix.right.clone(),
632 }))),
633 (None, Some(new_right)) => Ok(Some(Expression::Infix(InfixExpression {
634 token: infix.token.clone(),
635 left: infix.left.clone(),
636 operator: infix.operator.clone(),
637 op_type: infix.op_type,
638 right: Box::new(new_right),
639 }))),
640 (None, None) => Ok(None),
641 }
642 }
643
644 Expression::Infix(infix) if infix.operator.eq_ignore_ascii_case("OR") => {
645 // Try to optimize IN in either branch of OR
646 let left_opt = self.try_optimize_in_to_semi_join(&infix.left, ctx, outer_tables)?;
647 let right_opt =
648 self.try_optimize_in_to_semi_join(&infix.right, ctx, outer_tables)?;
649
650 match (left_opt, right_opt) {
651 (Some(new_left), Some(new_right)) => {
652 Ok(Some(Expression::Infix(InfixExpression {
653 token: infix.token.clone(),
654 left: Box::new(new_left),
655 operator: infix.operator.clone(),
656 op_type: infix.op_type,
657 right: Box::new(new_right),
658 })))
659 }
660 (Some(new_left), None) => Ok(Some(Expression::Infix(InfixExpression {
661 token: infix.token.clone(),
662 left: Box::new(new_left),
663 operator: infix.operator.clone(),
664 op_type: infix.op_type,
665 right: infix.right.clone(),
666 }))),
667 (None, Some(new_right)) => Ok(Some(Expression::Infix(InfixExpression {
668 token: infix.token.clone(),
669 left: infix.left.clone(),
670 operator: infix.operator.clone(),
671 op_type: infix.op_type,
672 right: Box::new(new_right),
673 }))),
674 (None, None) => Ok(None),
675 }
676 }
677
678 _ => Ok(None),
679 }
680 }
681
682 /// Extract semi-join info from an IN expression with subquery.
683 ///
684 /// Pattern: `outer.col IN (SELECT inner_col FROM t WHERE pred)`
685 ///
686 /// Returns None if:
687 /// - Subquery has more than one SELECT column
688 /// - Subquery has joins or derived tables
689 /// - WHERE clause references outer tables (correlated)
690 pub(super) fn try_extract_in_semi_join_info(
691 in_expr: &InExpression,
692 subquery: &SelectStatement,
693 outer_tables: &[String],
694 ) -> Option<SemiJoinInfo> {
695 // The EXISTS-oriented semi-join collector intentionally discards NULL.
696 // That is not equivalent for NOT IN: a single NULL makes every
697 // non-matching comparison UNKNOWN. Keep NOT IN on the exhaustive
698 // subquery rewriter, which records NULL in the compiled set.
699 if in_expr.not {
700 return None;
701 }
702 if subquery.with.is_some()
703 || subquery.distinct
704 || !subquery.distinct_on.is_empty()
705 || !subquery.group_by.columns.is_empty()
706 || subquery.group_by.modifier != GroupByModifier::None
707 || subquery.having.is_some()
708 || !subquery.window_defs.is_empty()
709 || !subquery.order_by.is_empty()
710 || subquery.limit.is_some()
711 || subquery.offset.is_some()
712 || !subquery.set_operations.is_empty()
713 {
714 return None;
715 }
716
717 // 1. Extract outer column from left side of IN
718 let (outer_column, outer_table): (String, Option<String>) = match in_expr.left.as_ref() {
719 Expression::QualifiedIdentifier(qid) => (
720 qid.name.value.to_string(),
721 Some(qid.qualifier.value.to_string()),
722 ),
723 Expression::Identifier(id) => (id.value.to_string(), None),
724 _ => return None, // Complex expression on left side, can't optimize
725 };
726
727 // 2. Subquery must SELECT exactly one column (not *)
728 if subquery.columns.len() != 1 {
729 return None;
730 }
731
732 // Extract inner column name from SELECT
733 let inner_column: String = match &subquery.columns[0] {
734 Expression::Identifier(id) => id.value.to_string(),
735 Expression::QualifiedIdentifier(qid) => qid.name.value.to_string(),
736 Expression::Aliased(a) => match a.expression.as_ref() {
737 Expression::Identifier(id) => id.value.to_string(),
738 Expression::QualifiedIdentifier(qid) => qid.name.value.to_string(),
739 _ => return None,
740 },
741 _ => return None, // Can't handle expressions in SELECT
742 };
743
744 // 3. Check for simple table source (not a join)
745 let (inner_table, inner_alias): (String, Option<String>) =
746 match subquery.table_expr.as_ref().map(|b| b.as_ref()) {
747 Some(Expression::TableSource(ts)) => {
748 if ts.as_of.is_some() {
749 return None;
750 }
751 let alias = ts.alias.as_ref().map(|a| a.value.to_string());
752 (ts.name.value.to_string(), alias)
753 }
754 _ => return None, // Can't optimize subquery joins or derived tables
755 };
756
757 // 4. Get inner table identifiers
758 let inner_table_lower: String = inner_alias
759 .clone()
760 .unwrap_or_else(|| inner_table.to_lowercase());
761 let inner_tables = vec![inner_table_lower.to_lowercase()];
762
763 // 5. Check if WHERE clause references outer tables
764 if let Some(ref where_clause) = subquery.where_clause {
765 if Self::expression_references_outer_tables(where_clause, outer_tables, &inner_tables) {
766 return None; // Correlated WHERE, can't optimize
767 }
768 }
769
770 Some(SemiJoinInfo {
771 outer_column,
772 outer_table,
773 inner_column,
774 inner_table,
775 inner_alias,
776 non_correlated_where: subquery
777 .where_clause
778 .as_ref()
779 .map(|b| Arc::new(b.as_ref().clone())),
780 is_negated: in_expr.not,
781 })
782 }
783
784 /// Get outer table names from a table expression (for semi-join optimization).
785 pub fn collect_outer_table_names(table_expr: &Option<Box<Expression>>) -> Vec<String> {
786 let mut tables = Vec::new();
787 if let Some(ref expr) = table_expr {
788 Self::collect_table_names_from_source(expr.as_ref(), &mut tables);
789 }
790 tables
791 }
792}