Skip to main content

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}