vibesql-executor 0.2.0

Query execution engine for vibesql SQL database
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Columnar Hash Join Implementation
//!
//! This module provides a high-performance hash join that operates entirely on
//! columnar data, avoiding row materialization overhead.
//!
//! ## Performance Characteristics
//!
//! - **Build phase**: O(n) with SIMD hash computation on contiguous arrays
//! - **Probe phase**: O(m) with SIMD hash computation and vectorized lookups
//! - **Memory**: O(n) for hash table + O(result_size) for output
//!
//! ## Key Optimizations
//!
//! 1. **No SqlValue enum dispatch**: Operates directly on typed arrays (i64, f64, etc.)
//! 2. **SIMD hashing**: Hashes 4-8 values simultaneously
//! 3. **Cache-friendly**: Contiguous memory access patterns
//! 4. **Deferred materialization**: Only creates rows at output if needed
//!
//! Note: This module is experimental/research code. Some functions are not yet
//! integrated into the main execution path.
//!
//! ## Module Structure
//!
//! - `hash_table`: Hash table implementations for single and multi-column keys
//! - `probe`: Probe phase implementations for inner and outer joins
//! - `output`: Result construction and column gathering
//! - `row_extract`: Utilities for extracting columnar data from row-based inputs

#![allow(dead_code)]

mod hash_table;
mod output;
mod probe;
mod row_extract;

// Re-export public types
pub use row_extract::{
    hash_join_indices_columnar, hash_join_indices_columnar_multi, hash_join_indices_columnar_str,
};

use crate::{errors::ExecutorError, select::columnar::ColumnarBatch};

/// Execute a columnar inner hash join
///
/// This function operates entirely on columnar data without materializing rows.
///
/// # Arguments
/// * `left_batch` - Left (probe) side columnar batch
/// * `right_batch` - Right (build) side columnar batch
/// * `left_key_idx` - Column index of join key in left batch
/// * `right_key_idx` - Column index of join key in right batch
///
/// # Returns
/// A new ColumnarBatch containing joined columns
pub fn columnar_hash_join_inner(
    left_batch: &ColumnarBatch,
    right_batch: &ColumnarBatch,
    left_key_idx: usize,
    right_key_idx: usize,
) -> Result<ColumnarBatch, ExecutorError> {
    // Get key columns
    let left_key =
        left_batch.column(left_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: left_key_idx,
            batch_columns: left_batch.column_count(),
        })?;
    let right_key =
        right_batch.column(right_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: right_key_idx,
            batch_columns: right_batch.column_count(),
        })?;

    // Build hash table on right (smaller side ideally)
    // For now, always build on right - could optimize to choose smaller side
    let hash_table = hash_table::ColumnarHashTable::build_from_column(right_key)?;

    // Probe and collect matching indices
    let join_indices = probe::probe_columnar(&hash_table, left_key, right_key)?;

    // Gather result columns
    output::gather_join_result(left_batch, right_batch, &join_indices)
}

/// Execute a columnar LEFT OUTER hash join
///
/// This function operates entirely on columnar data without materializing rows.
/// LEFT OUTER JOIN preserves all rows from the left (probe) side, outputting
/// NULL values for right columns when there's no match.
///
/// # Arguments
/// * `left_batch` - Left (probe) side columnar batch - all rows preserved
/// * `right_batch` - Right (build) side columnar batch
/// * `left_key_idx` - Column index of join key in left batch
/// * `right_key_idx` - Column index of join key in right batch
///
/// # Returns
/// A new ColumnarBatch containing joined columns with left rows preserved
pub fn columnar_hash_join_left_outer(
    left_batch: &ColumnarBatch,
    right_batch: &ColumnarBatch,
    left_key_idx: usize,
    right_key_idx: usize,
) -> Result<ColumnarBatch, ExecutorError> {
    // Get key columns
    let left_key =
        left_batch.column(left_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: left_key_idx,
            batch_columns: left_batch.column_count(),
        })?;
    let right_key =
        right_batch.column(right_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: right_key_idx,
            batch_columns: right_batch.column_count(),
        })?;

    // Build hash table on right side
    let hash_table = hash_table::ColumnarHashTable::build_from_column(right_key)?;

    // Probe and collect matching indices, tracking unmatched left rows
    let join_indices =
        probe::probe_columnar_left_outer(&hash_table, left_key, right_key, left_batch.row_count())?;

    // Gather result columns with NULL handling for unmatched rows
    output::gather_left_outer_result(left_batch, right_batch, &join_indices)
}

/// Execute a columnar RIGHT OUTER hash join
///
/// This function operates entirely on columnar data without materializing rows.
/// RIGHT OUTER JOIN preserves all rows from the right (build) side, outputting
/// NULL values for left columns when there's no match.
///
/// # Arguments
/// * `left_batch` - Left (probe) side columnar batch
/// * `right_batch` - Right (build) side columnar batch - all rows preserved
/// * `left_key_idx` - Column index of join key in left batch
/// * `right_key_idx` - Column index of join key in right batch
///
/// # Returns
/// A new ColumnarBatch containing joined columns with right rows preserved
pub fn columnar_hash_join_right_outer(
    left_batch: &ColumnarBatch,
    right_batch: &ColumnarBatch,
    left_key_idx: usize,
    right_key_idx: usize,
) -> Result<ColumnarBatch, ExecutorError> {
    // Get key columns
    let left_key =
        left_batch.column(left_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: left_key_idx,
            batch_columns: left_batch.column_count(),
        })?;
    let right_key =
        right_batch.column(right_key_idx).ok_or_else(|| ExecutorError::ColumnarColumnNotFound {
            column_index: right_key_idx,
            batch_columns: right_batch.column_count(),
        })?;

    // Build hash table on left side (reverse of inner join)
    let hash_table = hash_table::ColumnarHashTable::build_from_column(left_key)?;

    // Probe with right side and track unmatched right rows
    let join_indices = probe::probe_columnar_right_outer(
        &hash_table,
        right_key,
        left_key,
        right_batch.row_count(),
    )?;

    // Gather result columns with NULL handling for unmatched rows
    output::gather_right_outer_result(left_batch, right_batch, &join_indices)
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;
    use crate::select::columnar::ColumnArray;

    #[test]
    fn test_columnar_hash_join() {
        // Create left batch: customer_id, name
        let left_columns = vec![
            ColumnArray::Int64(Arc::new(vec![1, 2, 3, 4]), None),
            ColumnArray::String(
                Arc::new(vec!["Alice".into(), "Bob".into(), "Carol".into(), "Dave".into()]),
                None,
            ),
        ];
        let left_batch = ColumnarBatch::from_columns(
            left_columns,
            Some(vec!["customer_id".into(), "name".into()]),
        )
        .unwrap();

        // Create right batch: order_id, customer_id, amount
        let right_columns = vec![
            ColumnArray::Int64(Arc::new(vec![101, 102, 103, 104, 105]), None),
            ColumnArray::Int64(Arc::new(vec![1, 2, 1, 3, 2]), None),
            ColumnArray::Float64(Arc::new(vec![100.0, 200.0, 150.0, 300.0, 250.0]), None),
        ];
        let right_batch = ColumnarBatch::from_columns(
            right_columns,
            Some(vec!["order_id".into(), "customer_id".into(), "amount".into()]),
        )
        .unwrap();

        // Join on customer_id (left col 0 = right col 1)
        let result = columnar_hash_join_inner(&left_batch, &right_batch, 0, 1).unwrap();

        // Should have 5 result rows (Alice has 2 orders, Bob has 2, Carol has 1)
        assert_eq!(result.row_count(), 5);

        // Should have 5 columns (2 from left + 3 from right)
        assert_eq!(result.column_count(), 5);
    }

    #[test]
    fn test_columnar_hash_join_left_outer() {
        // Create left batch: customer_id, name (4 customers)
        let left_columns = vec![
            ColumnArray::Int64(Arc::new(vec![1, 2, 3, 4]), None),
            ColumnArray::String(
                Arc::new(vec!["Alice".into(), "Bob".into(), "Carol".into(), "Dave".into()]),
                None,
            ),
        ];
        let left_batch = ColumnarBatch::from_columns(
            left_columns,
            Some(vec!["customer_id".into(), "name".into()]),
        )
        .unwrap();

        // Create right batch: order_id, customer_id (only customers 1, 2, 3 have orders)
        let right_columns = vec![
            ColumnArray::Int64(Arc::new(vec![101, 102, 103]), None),
            ColumnArray::Int64(Arc::new(vec![1, 2, 1]), None), // Dave (id=4) has no orders
        ];
        let right_batch = ColumnarBatch::from_columns(
            right_columns,
            Some(vec!["order_id".into(), "customer_id".into()]),
        )
        .unwrap();

        // LEFT OUTER JOIN on customer_id (left col 0 = right col 1)
        let result = columnar_hash_join_left_outer(&left_batch, &right_batch, 0, 1).unwrap();

        // Should have 5 result rows:
        // - Alice (1): 2 matches (101, 103)
        // - Bob (2): 1 match (102)
        // - Carol (3): 0 matches, but preserved with NULLs
        // - Dave (4): 0 matches, but preserved with NULLs
        assert_eq!(result.row_count(), 5);
        assert_eq!(result.column_count(), 4); // 2 left + 2 right

        // Verify that all left rows are preserved
        let rows = result.to_rows().unwrap();

        // Count customers preserved
        let mut customer_counts = std::collections::HashMap::new();
        for row in &rows {
            if let Some(vibesql_types::SqlValue::Integer(id)) = row.get(0) {
                *customer_counts.entry(*id).or_insert(0) += 1;
            }
        }

        // Alice should appear 2 times, Bob 1 time, Carol 1 time, Dave 1 time
        assert_eq!(customer_counts.get(&1), Some(&2)); // Alice
        assert_eq!(customer_counts.get(&2), Some(&1)); // Bob
        assert_eq!(customer_counts.get(&3), Some(&1)); // Carol (preserved with NULL)
        assert_eq!(customer_counts.get(&4), Some(&1)); // Dave (preserved with NULL)
    }

    #[test]
    fn test_columnar_hash_join_right_outer() {
        // Create left batch: customer_id, name (2 customers)
        let left_columns = vec![
            ColumnArray::Int64(Arc::new(vec![1, 2]), None),
            ColumnArray::String(Arc::new(vec!["Alice".into(), "Bob".into()]), None),
        ];
        let left_batch = ColumnarBatch::from_columns(
            left_columns,
            Some(vec!["customer_id".into(), "name".into()]),
        )
        .unwrap();

        // Create right batch: order_id, customer_id (customer 3 has no matching left row)
        let right_columns = vec![
            ColumnArray::Int64(Arc::new(vec![101, 102, 103, 104]), None),
            ColumnArray::Int64(Arc::new(vec![1, 2, 3, 1]), None), /* Order 103 has customer_id=3, not in left */
        ];
        let right_batch = ColumnarBatch::from_columns(
            right_columns,
            Some(vec!["order_id".into(), "customer_id".into()]),
        )
        .unwrap();

        // RIGHT OUTER JOIN on customer_id (left col 0 = right col 1)
        let result = columnar_hash_join_right_outer(&left_batch, &right_batch, 0, 1).unwrap();

        // Should have 4 result rows (all right rows preserved):
        // - Order 101 (customer 1): matches Alice
        // - Order 102 (customer 2): matches Bob
        // - Order 103 (customer 3): no match, left columns are NULL
        // - Order 104 (customer 1): matches Alice
        assert_eq!(result.row_count(), 4);
        assert_eq!(result.column_count(), 4); // 2 left + 2 right

        // Verify that all right rows are preserved
        let rows = result.to_rows().unwrap();

        // Count by order_id (column 2 in result)
        let mut order_found = std::collections::HashSet::new();
        let mut null_customer_count = 0;

        for row in &rows {
            if let Some(vibesql_types::SqlValue::Integer(order_id)) = row.get(2) {
                order_found.insert(*order_id);
            }
            if let Some(vibesql_types::SqlValue::Null) = row.get(0) {
                null_customer_count += 1;
            }
        }

        // All 4 orders should be present
        assert!(order_found.contains(&101));
        assert!(order_found.contains(&102));
        assert!(order_found.contains(&103));
        assert!(order_found.contains(&104));

        // One row should have NULL customer (order 103)
        assert_eq!(null_customer_count, 1);
    }

    #[test]
    fn test_columnar_hash_join_with_nulls() {
        // Create left batch with NULL key
        let left_columns = vec![
            ColumnArray::Int64(
                Arc::new(vec![1, 0, 3]),                  // 0 is placeholder for NULL
                Some(Arc::new(vec![false, true, false])), // Index 1 is NULL
            ),
            ColumnArray::String(Arc::new(vec!["Alice".into(), "Bob".into(), "Carol".into()]), None),
        ];
        let left_batch =
            ColumnarBatch::from_columns(left_columns, Some(vec!["id".into(), "name".into()]))
                .unwrap();

        // Create right batch
        let right_columns = vec![
            ColumnArray::Int64(Arc::new(vec![1, 3]), None),
            ColumnArray::String(Arc::new(vec!["Order1".into(), "Order3".into()]), None),
        ];
        let right_batch =
            ColumnarBatch::from_columns(right_columns, Some(vec!["id".into(), "desc".into()]))
                .unwrap();

        // LEFT OUTER JOIN - Bob (NULL key) should be preserved with NULL right columns
        let result = columnar_hash_join_left_outer(&left_batch, &right_batch, 0, 0).unwrap();

        // Should have 3 rows: Alice matches, Bob (NULL key) preserved, Carol matches
        assert_eq!(result.row_count(), 3);

        // Verify Bob's row has NULL for right side
        let rows = result.to_rows().unwrap();
        let bob_row = rows
            .iter()
            .find(|r| matches!(r.get(1), Some(vibesql_types::SqlValue::Varchar(s)) if s.as_str() == "Bob"));

        assert!(bob_row.is_some());
        let bob = bob_row.unwrap();
        // Bob's right-side columns should be NULL
        assert!(matches!(bob.get(2), Some(vibesql_types::SqlValue::Null)));
        assert!(matches!(bob.get(3), Some(vibesql_types::SqlValue::Null)));
    }

    /// Regression test for issue #5189: LEFT OUTER JOIN must preserve pre-existing
    /// NULL values in matched rows. Previously, `gather_column_with_nulls` ignored
    /// the source's null bitmap and unconditionally wrote `false` (not-null) for
    /// every matched index, so a NULL value in the matched right-side data was
    /// rendered as the type's default placeholder (e.g. `0` for Int64).
    #[test]
    fn test_columnar_hash_join_left_outer_preserves_matched_nulls() {
        // Left: a single row with key 'X'
        let left_columns = vec![ColumnArray::String(Arc::new(vec!["X".into()]), None)];
        let left_batch =
            ColumnarBatch::from_columns(left_columns, Some(vec!["x".into()])).unwrap();

        // Right: three rows all matching key 'X', but row index 1 has c = NULL.
        // c is the value column (col 0), d is the join key (col 1).
        let right_columns = vec![
            ColumnArray::Int64(
                Arc::new(vec![5, 0, 7]),                  // 0 is placeholder for NULL
                Some(Arc::new(vec![false, true, false])), // row 1 is NULL
            ),
            ColumnArray::String(
                Arc::new(vec!["X".into(), "X".into(), "X".into()]),
                None,
            ),
        ];
        let right_batch =
            ColumnarBatch::from_columns(right_columns, Some(vec!["c".into(), "d".into()]))
                .unwrap();

        // LEFT JOIN t1 ON d = x — three matches.
        // left key col = 0 (x), right key col = 1 (d).
        let result = columnar_hash_join_left_outer(&left_batch, &right_batch, 0, 1).unwrap();
        assert_eq!(result.row_count(), 3);

        let rows = result.to_rows().unwrap();
        // Result columns are: x (0), c (1), d (2). Exactly one c must be NULL.
        let null_c_count = rows
            .iter()
            .filter(|r| matches!(r.get(1), Some(vibesql_types::SqlValue::Null)))
            .count();
        assert_eq!(
            null_c_count, 1,
            "matched-row NULL in c must be preserved; got rows: {:?}",
            rows
        );
    }
}