#![allow(clippy::doc_lazy_continuation)]
use std::collections::HashSet;
use super::{combine_rows, hash_join::build_existence_hash_table_parallel, FromResult};
use crate::{
errors::ExecutorError,
evaluator::CombinedExpressionEvaluator,
optimizer::{
combine_with_and,
where_pushdown::{extract_referenced_tables_branch, flatten_conjuncts},
},
schema::CombinedSchema,
timeout::{TimeoutContext, CHECK_INTERVAL},
};
use vibesql_types::{SqlValue, TypeAffinity};
pub(super) fn get_column_affinity(schema: &CombinedSchema, col_idx: usize) -> Option<TypeAffinity> {
for (start_idx, table_schema) in schema.table_schemas.values() {
let end_idx = start_idx + table_schema.columns.len();
if col_idx >= *start_idx && col_idx < end_idx {
let local_idx = col_idx - start_idx;
return Some(table_schema.columns[local_idx].data_type.sqlite_affinity());
}
}
None
}
pub(super) fn normalize_for_affinity_hash(
value: &SqlValue,
target_affinity: Option<TypeAffinity>,
) -> SqlValue {
match (value, target_affinity) {
(
SqlValue::Varchar(s) | SqlValue::Character(s),
Some(TypeAffinity::Integer | TypeAffinity::Real | TypeAffinity::Numeric),
) => {
let s_str = s.as_str().trim();
if let Ok(i) = s_str.parse::<i64>() {
return SqlValue::Integer(i);
}
if let Ok(f) = s_str.parse::<f64>() {
if matches!(target_affinity, Some(TypeAffinity::Integer))
&& f.fract() == 0.0
&& f.is_finite()
{
if f >= i64::MIN as f64 && f <= i64::MAX as f64 {
return SqlValue::Integer(f as i64);
}
}
return SqlValue::Double(f);
}
value.clone()
}
(SqlValue::Integer(i), Some(TypeAffinity::Text)) => {
SqlValue::Varchar(arcstr::ArcStr::from(i.to_string()))
}
(SqlValue::Double(f), Some(TypeAffinity::Text)) => {
SqlValue::Varchar(arcstr::ArcStr::from(format_float_for_text(*f)))
}
(SqlValue::Real(f), Some(TypeAffinity::Text)) => {
SqlValue::Varchar(arcstr::ArcStr::from(format_float_for_text(*f as f64)))
}
(SqlValue::Float(f), Some(TypeAffinity::Text)) => {
SqlValue::Varchar(arcstr::ArcStr::from(format_float_for_text(*f as f64)))
}
(SqlValue::Double(f), Some(TypeAffinity::Integer)) if f.fract() == 0.0 && f.is_finite() => {
if *f >= i64::MIN as f64 && *f <= i64::MAX as f64 {
SqlValue::Integer(*f as i64)
} else {
value.clone()
}
}
(SqlValue::Real(f), Some(TypeAffinity::Integer))
if (*f as f64).fract() == 0.0 && f.is_finite() =>
{
let f64_val = *f as f64;
if f64_val >= i64::MIN as f64 && f64_val <= i64::MAX as f64 {
SqlValue::Integer(f64_val as i64)
} else {
value.clone()
}
}
(SqlValue::Float(f), Some(TypeAffinity::Integer))
if (*f as f64).fract() == 0.0 && f.is_finite() =>
{
let f64_val = *f as f64;
if f64_val >= i64::MIN as f64 && f64_val <= i64::MAX as f64 {
SqlValue::Integer(f64_val as i64)
} else {
value.clone()
}
}
_ => value.clone(),
}
}
fn format_float_for_text(f: f64) -> String {
if f.fract() == 0.0 && f.abs() < 1e15 {
format!("{:.1}", f) } else {
f.to_string()
}
}
pub(super) fn hash_semi_join(
left: FromResult,
right: FromResult,
left_col_idx: usize,
right_col_idx: usize,
) -> Result<FromResult, ExecutorError> {
let timeout_ctx = TimeoutContext::new_default();
let left_affinity = get_column_affinity(&left.schema, left_col_idx);
let right_affinity = get_column_affinity(&right.schema, right_col_idx);
let needs_affinity_aware = match (left_affinity, right_affinity) {
(
Some(TypeAffinity::Text),
Some(TypeAffinity::Integer | TypeAffinity::Real | TypeAffinity::Numeric),
) => true,
(
Some(TypeAffinity::Integer | TypeAffinity::Real | TypeAffinity::Numeric),
Some(TypeAffinity::Text),
) => true,
(Some(TypeAffinity::Integer), Some(TypeAffinity::Real)) => true,
(Some(TypeAffinity::Real), Some(TypeAffinity::Integer)) => true,
_ => false,
};
let left_slice = left.as_slice();
let right_slice = right.as_slice();
if needs_affinity_aware {
hash_semi_join_with_affinity(
left_slice,
right_slice,
left_col_idx,
right_col_idx,
left_affinity,
right_affinity,
&timeout_ctx,
&left.schema,
)
} else {
let hash_table =
build_existence_hash_table_parallel(right_slice, right_col_idx, &timeout_ctx)?;
let estimated_capacity = left_slice.len().min(100_000);
let mut result_rows = Vec::with_capacity(estimated_capacity);
for (idx, left_row) in left_slice.iter().enumerate() {
if idx % CHECK_INTERVAL == 0 {
timeout_ctx.check()?;
}
let key = &left_row.values[left_col_idx];
if key == &SqlValue::Null {
continue;
}
if hash_table.contains_key(key) {
result_rows.push(left_row.clone());
}
}
Ok(FromResult::from_rows(left.schema.clone(), result_rows))
}
}
fn hash_semi_join_with_affinity(
left_slice: &[vibesql_storage::Row],
right_slice: &[vibesql_storage::Row],
left_col_idx: usize,
right_col_idx: usize,
left_affinity: Option<TypeAffinity>,
right_affinity: Option<TypeAffinity>,
timeout_ctx: &TimeoutContext,
left_schema: &CombinedSchema,
) -> Result<FromResult, ExecutorError> {
use ahash::AHashSet;
let target_affinity = match (left_affinity, right_affinity) {
(_, Some(TypeAffinity::Integer)) => Some(TypeAffinity::Integer),
(_, Some(TypeAffinity::Real)) => Some(TypeAffinity::Real),
(_, Some(TypeAffinity::Numeric)) => Some(TypeAffinity::Numeric),
(Some(TypeAffinity::Integer), Some(TypeAffinity::Text)) => Some(TypeAffinity::Integer),
(Some(TypeAffinity::Real), Some(TypeAffinity::Text)) => Some(TypeAffinity::Real),
(Some(TypeAffinity::Numeric), Some(TypeAffinity::Text)) => Some(TypeAffinity::Numeric),
_ => right_affinity,
};
let mut hash_set: AHashSet<SqlValue> = AHashSet::with_capacity(right_slice.len());
for (idx, row) in right_slice.iter().enumerate() {
if idx % CHECK_INTERVAL == 0 {
timeout_ctx.check()?;
}
let key = &row.values[right_col_idx];
if key == &SqlValue::Null {
continue;
}
let normalized_key = normalize_for_affinity_hash(key, target_affinity);
hash_set.insert(normalized_key);
}
let estimated_capacity = left_slice.len().min(100_000);
let mut result_rows = Vec::with_capacity(estimated_capacity);
for (idx, left_row) in left_slice.iter().enumerate() {
if idx % CHECK_INTERVAL == 0 {
timeout_ctx.check()?;
}
let key = &left_row.values[left_col_idx];
if key == &SqlValue::Null {
continue;
}
let normalized_key = normalize_for_affinity_hash(key, target_affinity);
if hash_set.contains(&normalized_key) {
result_rows.push(left_row.clone());
}
}
Ok(FromResult::from_rows(left_schema.clone(), result_rows))
}
pub(super) fn hash_semi_join_with_filter(
left: FromResult,
right: FromResult,
left_col_idx: usize,
right_col_idx: usize,
additional_filter: Option<&vibesql_ast::Expression>,
combined_schema: &CombinedSchema,
database: &vibesql_storage::Database,
) -> Result<FromResult, ExecutorError> {
let debug = std::env::var("SEMI_JOIN_DEBUG").is_ok();
let start = std::time::Instant::now();
if additional_filter.is_none() {
return hash_semi_join(left, right, left_col_idx, right_col_idx);
}
let timeout_ctx = TimeoutContext::new_default();
let filter = additional_filter.unwrap();
let left_slice = left.as_slice();
let right_slice = right.as_slice();
if debug {
eprintln!("[SEMI_JOIN] left_rows={}, right_rows={}", left_slice.len(), right_slice.len());
eprintln!("[SEMI_JOIN] filter={:?}", filter);
}
let (right_only_filter, probe_filter) =
partition_filter_predicates(filter, combined_schema, &left.schema, &right.schema);
if debug {
eprintln!("[SEMI_JOIN] right_only_filter={:?}", right_only_filter);
eprintln!("[SEMI_JOIN] probe_filter={:?}", probe_filter);
}
let right_only_evaluator = right_only_filter
.as_ref()
.map(|_| CombinedExpressionEvaluator::with_database(&right.schema, database));
let probe_evaluator = probe_filter
.as_ref()
.map(|_| CombinedExpressionEvaluator::with_database(combined_schema, database));
use ahash::AHashMap;
let mut hash_table: AHashMap<vibesql_types::SqlValue, Vec<usize>> = AHashMap::new();
let mut _filtered_count = 0usize;
for (idx, row) in right_slice.iter().enumerate() {
if idx % CHECK_INTERVAL == 0 {
timeout_ctx.check()?;
}
let key = row.values[right_col_idx].clone();
if key == vibesql_types::SqlValue::Null {
continue;
}
if let (Some(right_filter), Some(evaluator)) = (&right_only_filter, &right_only_evaluator) {
evaluator.clear_cse_cache();
match evaluator.eval(right_filter, row) {
Ok(vibesql_types::SqlValue::Boolean(true)) => {
hash_table.entry(key).or_default().push(idx);
}
Ok(vibesql_types::SqlValue::Boolean(false)) | Ok(vibesql_types::SqlValue::Null) => {
if debug && _filtered_count < 3 {
eprintln!(
"[SEMI_JOIN] Filtered out row: {:?}",
&row.values[..3.min(row.values.len())]
);
}
_filtered_count += 1;
continue;
}
Err(e) => {
if debug && _filtered_count < 3 {
eprintln!("[SEMI_JOIN] Error evaluating filter: {:?}", e);
}
_filtered_count += 1;
continue;
}
Ok(v) => {
if debug && _filtered_count < 3 {
eprintln!("[SEMI_JOIN] Non-boolean filter result: {:?}", v);
}
_filtered_count += 1;
continue;
}
}
} else {
hash_table.entry(key).or_default().push(idx);
}
}
let build_time = start.elapsed();
if debug {
eprintln!(
"[SEMI_JOIN] build_time={:?}, hash_table_size={}, filtered_count={}",
build_time,
hash_table.len(),
_filtered_count
);
}
let estimated_capacity = left_slice.len().min(100_000);
let mut result_rows = Vec::with_capacity(estimated_capacity);
let mut probe_iterations = 0;
for left_row in left_slice.iter() {
probe_iterations += 1;
if probe_iterations % CHECK_INTERVAL == 0 {
timeout_ctx.check()?;
}
let key = &left_row.values[left_col_idx];
if key == &vibesql_types::SqlValue::Null {
continue;
}
if let Some(right_indices) = hash_table.get(key) {
if probe_filter.is_none() {
if !right_indices.is_empty() {
result_rows.push(left_row.clone());
}
continue;
}
let mut found_match = false;
let evaluator = probe_evaluator.as_ref().unwrap();
let pf = probe_filter.as_ref().unwrap();
for &right_idx in right_indices {
let right_row = &right_slice[right_idx];
let combined_row = combine_rows(left_row, right_row);
evaluator.clear_cse_cache();
match evaluator.eval(pf, &combined_row) {
Ok(vibesql_types::SqlValue::Boolean(true)) => {
found_match = true;
break; }
Ok(vibesql_types::SqlValue::Boolean(false))
| Ok(vibesql_types::SqlValue::Null) => continue,
Err(_) => continue, Ok(_) => continue, }
}
if found_match {
result_rows.push(left_row.clone());
}
}
}
if debug {
let total_time = start.elapsed();
eprintln!(
"[SEMI_JOIN] probe_time={:?}, total_time={:?}, result_rows={}",
total_time - build_time,
total_time,
result_rows.len()
);
}
Ok(FromResult::from_rows(left.schema.clone(), result_rows))
}
pub(super) fn partition_filter_predicates(
filter: &vibesql_ast::Expression,
combined_schema: &CombinedSchema,
left_schema: &CombinedSchema,
right_schema: &CombinedSchema,
) -> (Option<vibesql_ast::Expression>, Option<vibesql_ast::Expression>) {
let right_table_names: HashSet<String> = right_schema.table_names().into_iter().collect();
let left_table_names: HashSet<String> = left_schema.table_names().into_iter().collect();
let conjuncts = flatten_conjuncts(filter);
let mut right_only_predicates = Vec::new();
let mut cross_table_predicates = Vec::new();
for conjunct in conjuncts {
if let Some(referenced_tables) =
extract_referenced_tables_branch(&conjunct, combined_schema)
{
let refs_left = referenced_tables.iter().any(|t| left_table_names.contains(t));
let refs_right = referenced_tables.iter().any(|t| right_table_names.contains(t));
if refs_right && !refs_left {
right_only_predicates.push(conjunct);
} else {
cross_table_predicates.push(conjunct);
}
} else {
cross_table_predicates.push(conjunct);
}
}
let right_only = combine_with_and(right_only_predicates);
let cross_table = combine_with_and(cross_table_predicates);
(right_only, cross_table)
}
#[cfg(test)]
mod tests {
use vibesql_catalog::{ColumnSchema, TableSchema};
use vibesql_storage::Row;
use vibesql_types::{DataType, SqlValue};
use super::*;
use crate::schema::CombinedSchema;
fn create_test_from_result(
table_name: &str,
columns: Vec<(&str, DataType)>,
rows: Vec<Vec<SqlValue>>,
) -> FromResult {
let schema = TableSchema::new(
table_name.to_string(),
columns
.iter()
.map(|(name, dtype)| {
ColumnSchema::new(
name.to_string(),
dtype.clone(),
true, )
})
.collect(),
);
let combined_schema = CombinedSchema::from_table(table_name.to_string(), schema);
let rows = rows.into_iter().map(Row::new).collect();
FromResult::from_rows(combined_schema, rows)
}
#[test]
fn test_hash_semi_join_basic() {
let left = create_test_from_result(
"users",
vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: Some(50) })],
vec![
vec![SqlValue::Integer(1), SqlValue::Varchar(arcstr::ArcStr::from("Alice"))],
vec![SqlValue::Integer(2), SqlValue::Varchar(arcstr::ArcStr::from("Bob"))],
vec![SqlValue::Integer(3), SqlValue::Varchar(arcstr::ArcStr::from("Charlie"))],
],
);
let right = create_test_from_result(
"orders",
vec![("user_id", DataType::Integer), ("amount", DataType::Integer)],
vec![
vec![SqlValue::Integer(1), SqlValue::Integer(100)],
vec![SqlValue::Integer(2), SqlValue::Integer(200)],
vec![SqlValue::Integer(1), SqlValue::Integer(150)],
],
);
let mut result = hash_semi_join(left, right, 0, 0).unwrap();
assert_eq!(result.rows().len(), 2);
for row in result.rows() {
assert_eq!(row.values.len(), 2);
}
let user_ids: Vec<i64> = result
.rows()
.iter()
.map(|r| match &r.values[0] {
SqlValue::Integer(id) => *id,
_ => panic!("Expected integer"),
})
.collect();
assert!(user_ids.contains(&1));
assert!(user_ids.contains(&2));
assert!(!user_ids.contains(&3)); }
#[test]
fn test_hash_semi_join_null_values() {
let left = create_test_from_result(
"users",
vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: Some(50) })],
vec![
vec![SqlValue::Integer(1), SqlValue::Varchar(arcstr::ArcStr::from("Alice"))],
vec![SqlValue::Null, SqlValue::Varchar(arcstr::ArcStr::from("Unknown"))],
],
);
let right = create_test_from_result(
"orders",
vec![("user_id", DataType::Integer), ("amount", DataType::Integer)],
vec![
vec![SqlValue::Integer(1), SqlValue::Integer(100)],
vec![SqlValue::Null, SqlValue::Integer(200)],
],
);
let mut result = hash_semi_join(left, right, 0, 0).unwrap();
assert_eq!(result.rows().len(), 1);
assert_eq!(result.rows()[0].values[0], SqlValue::Integer(1));
assert_eq!(result.rows()[0].values[1], SqlValue::Varchar(arcstr::ArcStr::from("Alice")));
}
#[test]
fn test_hash_semi_join_no_matches() {
let left = create_test_from_result(
"users",
vec![("id", DataType::Integer)],
vec![vec![SqlValue::Integer(1)], vec![SqlValue::Integer(2)]],
);
let right = create_test_from_result(
"orders",
vec![("user_id", DataType::Integer)],
vec![vec![SqlValue::Integer(3)], vec![SqlValue::Integer(4)]],
);
let mut result = hash_semi_join(left, right, 0, 0).unwrap();
assert_eq!(result.rows().len(), 0);
}
#[test]
fn test_hash_semi_join_duplicate_right_keys() {
let left = create_test_from_result(
"users",
vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: Some(50) })],
vec![
vec![SqlValue::Integer(1), SqlValue::Varchar(arcstr::ArcStr::from("Alice"))],
vec![SqlValue::Integer(2), SqlValue::Varchar(arcstr::ArcStr::from("Bob"))],
],
);
let right = create_test_from_result(
"orders",
vec![("user_id", DataType::Integer), ("amount", DataType::Integer)],
vec![
vec![SqlValue::Integer(1), SqlValue::Integer(100)],
vec![SqlValue::Integer(1), SqlValue::Integer(150)],
vec![SqlValue::Integer(1), SqlValue::Integer(200)],
vec![SqlValue::Integer(1), SqlValue::Integer(250)],
],
);
let mut result = hash_semi_join(left, right, 0, 0).unwrap();
assert_eq!(result.rows().len(), 1);
assert_eq!(result.rows()[0].values[0], SqlValue::Integer(1));
assert_eq!(result.rows()[0].values[1], SqlValue::Varchar(arcstr::ArcStr::from("Alice")));
}
}