Skip to main content

wasm_dbms/
join.rs

1// Rust guideline compliant 2026-03-01
2// X-WHERE-CLAUSE, M-CANONICAL-DOCS
3
4//! Join execution engine for cross-table queries.
5
6use std::collections::HashSet;
7
8use wasm_dbms_api::prelude::{
9    ColumnDef, DbmsResult, JoinColumnDef, JoinType, OrderDirection, Query, Value,
10};
11use wasm_dbms_memory::prelude::MemoryProvider;
12
13use crate::database::WasmDbmsDatabase;
14use crate::schema::DatabaseSchema;
15
16/// A row in the joined result, organized by source table.
17type JoinedRow = Vec<(String, Vec<(ColumnDef, Value)>)>;
18
19/// Schema columns of every table in a joined row, in row order.
20type JoinedTableColumns = Vec<(String, &'static [ColumnDef])>;
21
22/// Engine that executes join queries using nested-loop join.
23pub struct JoinEngine<'a, Schema: ?Sized, M>
24where
25    Schema: DatabaseSchema<M>,
26    M: MemoryProvider,
27{
28    schema: &'a Schema,
29    _marker: std::marker::PhantomData<M>,
30}
31
32impl<'a, Schema: ?Sized, M> JoinEngine<'a, Schema, M>
33where
34    Schema: DatabaseSchema<M>,
35    M: MemoryProvider,
36{
37    pub fn new(schema: &'a Schema) -> Self {
38        Self {
39            schema,
40            _marker: std::marker::PhantomData,
41        }
42    }
43}
44
45impl<Schema: ?Sized, M> JoinEngine<'_, Schema, M>
46where
47    Schema: DatabaseSchema<M>,
48    M: MemoryProvider,
49{
50    /// Executes a join query using nested-loop join.
51    pub fn join(
52        &self,
53        dbms: &WasmDbmsDatabase<'_, M>,
54        from_table: &str,
55        query: Query,
56    ) -> DbmsResult<Vec<Vec<(JoinColumnDef, Value)>>> {
57        let mut table_columns: JoinedTableColumns = vec![(
58            from_table.to_string(),
59            self.schema.table_columns(from_table)?,
60        )];
61        for join in &query.joins {
62            table_columns.push((join.table.clone(), self.schema.table_columns(&join.table)?));
63        }
64
65        if let Some(filter) = &query.filter {
66            let groups: Vec<(&str, &[ColumnDef])> = table_columns
67                .iter()
68                .map(|(table, columns)| (table.as_str(), *columns))
69                .collect();
70            filter.validate_joined(&groups)?;
71        }
72
73        let from_rows = self
74            .schema
75            .select(dbms, from_table, Query::builder().all().build())?;
76
77        let mut joined_rows: Vec<JoinedRow> = from_rows
78            .into_iter()
79            .map(|row| vec![(from_table.to_string(), row)])
80            .collect();
81        // Schema columns of the tables already in `joined_rows`, used to
82        // NULL-pad outer joins even when one side has no rows.
83        let mut left_tables: JoinedTableColumns = vec![table_columns[0].clone()];
84
85        for (join_index, join) in query.joins.iter().enumerate() {
86            let (left_table, left_col) = self.resolve_column_ref(&join.left_column, from_table);
87            let (_right_table_ref, right_col) =
88                self.resolve_column_ref(&join.right_column, &join.table);
89
90            let (keep_unmatched_left, keep_unmatched_right) = match join.join_type {
91                JoinType::Inner => (false, false),
92                JoinType::Left => (true, false),
93                JoinType::Right => (false, true),
94                JoinType::Full => (true, true),
95            };
96
97            let right_rows = self.load_join_right_rows(
98                dbms,
99                &joined_rows,
100                &join.table,
101                &left_table,
102                left_col,
103                right_col,
104                keep_unmatched_right,
105            )?;
106
107            let right_columns = table_columns[join_index + 1].1;
108
109            joined_rows = self.nested_loop_join(
110                joined_rows,
111                &right_rows,
112                &left_tables,
113                &join.table,
114                right_columns,
115                &left_table,
116                left_col,
117                right_col,
118                keep_unmatched_left,
119                keep_unmatched_right,
120            );
121            left_tables.push((join.table.clone(), right_columns));
122        }
123
124        if let Some(filter) = &query.filter {
125            let mut filtered_rows = Vec::with_capacity(joined_rows.len());
126            for row in joined_rows {
127                let groups: Vec<(&str, Vec<(ColumnDef, Value)>)> = row
128                    .iter()
129                    .map(|(t, cols)| (t.as_str(), cols.clone()))
130                    .collect();
131                // Evaluation errors (ambiguous or out-of-scope columns, invalid
132                // operands) are query errors, not non-matching rows.
133                if filter.matches_joined_row(&groups)? {
134                    filtered_rows.push(row);
135                }
136            }
137            joined_rows = filtered_rows;
138        }
139
140        for (column, direction) in query.order_by.iter().rev() {
141            self.sort_joined_rows(&mut joined_rows, column, *direction);
142        }
143
144        let offset = query.offset.unwrap_or_default();
145        if offset > 0 {
146            if offset >= joined_rows.len() {
147                joined_rows.clear();
148            } else {
149                joined_rows = joined_rows.into_iter().skip(offset).collect();
150            }
151        }
152
153        if let Some(limit) = query.limit {
154            joined_rows.truncate(limit);
155        }
156
157        let results = joined_rows
158            .into_iter()
159            .map(|row| self.flatten_joined_row(row, &query))
160            .collect::<DbmsResult<Vec<_>>>()?;
161
162        Ok(results)
163    }
164
165    #[expect(
166        clippy::too_many_arguments,
167        reason = "arguments are necessary for loading right table rows based on join conditions"
168    )]
169    fn load_join_right_rows(
170        &self,
171        dbms: &WasmDbmsDatabase<'_, M>,
172        left_rows: &[JoinedRow],
173        right_table: &str,
174        left_table: &str,
175        left_col: &str,
176        right_col: &str,
177        keep_unmatched_right: bool,
178    ) -> DbmsResult<Vec<Vec<(ColumnDef, Value)>>> {
179        let unique_join_values: Vec<Value> = {
180            let mut seen = HashSet::new();
181            left_rows
182                .iter()
183                .filter_map(|row| self.get_column_value(row, left_table, left_col).cloned())
184                .filter(|value| !value.is_null())
185                .filter(|value| seen.insert(value.clone()))
186                .collect()
187        };
188
189        if keep_unmatched_right {
190            return self
191                .schema
192                .select(dbms, right_table, Query::builder().all().build());
193        }
194
195        if unique_join_values.is_empty() {
196            return Ok(Vec::new());
197        }
198
199        self.schema.select(
200            dbms,
201            right_table,
202            Query::builder()
203                .all()
204                .filter(Some(wasm_dbms_api::prelude::Filter::in_list(
205                    right_col,
206                    unique_join_values,
207                )))
208                .build(),
209        )
210    }
211
212    /// Unified nested-loop join.
213    ///
214    /// Unmatched rows kept by an outer join are padded with `NULL` values for
215    /// every schema column of the missing side, taken from `left_tables` and
216    /// `right_columns` rather than from sample rows, so the row shape stays
217    /// complete when that side is empty.
218    #[allow(clippy::too_many_arguments)]
219    fn nested_loop_join(
220        &self,
221        left_rows: Vec<JoinedRow>,
222        right_rows: &[Vec<(ColumnDef, Value)>],
223        left_tables: &[(String, &'static [ColumnDef])],
224        right_table: &str,
225        right_columns: &[ColumnDef],
226        left_table: &str,
227        left_col: &str,
228        right_col: &str,
229        keep_unmatched_left: bool,
230        keep_unmatched_right: bool,
231    ) -> Vec<JoinedRow> {
232        let mut results = Vec::new();
233        let mut right_matched = vec![false; right_rows.len()];
234
235        for left_row in &left_rows {
236            let left_value = self.get_column_value(left_row, left_table, left_col);
237            let mut matched = false;
238
239            for (i, right_row) in right_rows.iter().enumerate() {
240                let right_value = right_row
241                    .iter()
242                    .find(|(c, _)| c.name == right_col)
243                    .map(|(_, v)| v);
244
245                if let (Some(left), Some(right)) = (left_value, right_value)
246                    && !left.is_null()
247                    && !right.is_null()
248                    && left == right
249                {
250                    let mut new_row = left_row.clone();
251                    new_row.push((right_table.to_string(), right_row.clone()));
252                    results.push(new_row);
253                    right_matched[i] = true;
254                    matched = true;
255                }
256            }
257
258            if keep_unmatched_left && !matched {
259                let mut new_row = left_row.clone();
260                new_row.push((
261                    right_table.to_string(),
262                    self.null_pad_columns(right_columns),
263                ));
264                results.push(new_row);
265            }
266        }
267
268        if keep_unmatched_right {
269            for (i, right_row) in right_rows.iter().enumerate() {
270                if !right_matched[i] {
271                    let mut new_row: JoinedRow = left_tables
272                        .iter()
273                        .map(|(table_name, columns)| {
274                            (table_name.clone(), self.null_pad_columns(columns))
275                        })
276                        .collect();
277                    new_row.push((right_table.to_string(), right_row.clone()));
278                    results.push(new_row);
279                }
280            }
281        }
282
283        results
284    }
285
286    /// Resolves a column reference to (table_name, column_name).
287    fn resolve_column_ref<'a>(&self, field: &'a str, default_table: &'a str) -> (String, &'a str) {
288        if let Some((table, column)) = field.split_once('.') {
289            (table.to_string(), column)
290        } else {
291            (default_table.to_string(), field)
292        }
293    }
294
295    /// Finds a column value in a joined row.
296    fn get_column_value<'a>(
297        &self,
298        row: &'a JoinedRow,
299        table: &str,
300        column: &str,
301    ) -> Option<&'a Value> {
302        row.iter()
303            .find(|(t, _)| t == table)
304            .and_then(|(_, cols)| cols.iter().find(|(c, _)| c.name == column).map(|(_, v)| v))
305    }
306
307    /// Creates a NULL-padded row from a table's schema columns.
308    fn null_pad_columns(&self, columns: &[ColumnDef]) -> Vec<(ColumnDef, Value)> {
309        columns.iter().map(|col| (*col, Value::Null)).collect()
310    }
311
312    /// Sorts joined rows by a column.
313    fn sort_joined_rows(&self, rows: &mut [JoinedRow], column: &str, direction: OrderDirection) {
314        let (table, col) = if let Some((t, c)) = column.split_once('.') {
315            (Some(t), c)
316        } else {
317            (None, column)
318        };
319
320        rows.sort_by(|a, b| {
321            let a_val = self.find_value_in_joined_row(a, table, col);
322            let b_val = self.find_value_in_joined_row(b, table, col);
323
324            crate::database::sort_values_with_direction(a_val, b_val, direction)
325        });
326    }
327
328    /// Finds a column value in a joined row, optionally scoped to a table.
329    fn find_value_in_joined_row<'a>(
330        &self,
331        row: &'a JoinedRow,
332        table: Option<&str>,
333        column: &str,
334    ) -> Option<&'a Value> {
335        if let Some(table) = table {
336            return self.get_column_value(row, table, column);
337        }
338        row.iter()
339            .flat_map(|(_, cols)| cols)
340            .find_map(|(col, value)| {
341                if col.name == column {
342                    Some(value)
343                } else {
344                    None
345                }
346            })
347    }
348
349    /// Flattens a joined row into the output format.
350    fn flatten_joined_row(
351        &self,
352        row: JoinedRow,
353        query: &Query,
354    ) -> DbmsResult<Vec<(JoinColumnDef, Value)>> {
355        let mut result = Vec::new();
356
357        for (table_name, cols) in row {
358            for (col, val) in cols {
359                let mut candid_col = JoinColumnDef::from(col);
360                candid_col.table = Some(table_name.clone());
361
362                if !query.all_selected() {
363                    let selected = query.raw_columns();
364                    let qualified_name = format!("{table_name}.{col}", col = candid_col.name);
365                    if !selected.contains(&candid_col.name) && !selected.contains(&qualified_name) {
366                        continue;
367                    }
368                }
369
370                result.push((candid_col, val));
371            }
372        }
373
374        Ok(result)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380
381    use wasm_dbms_api::prelude::{
382        Database as _, DbmsError, Filter, InsertRecord as _, JoinColumnDef, Nullable, Query,
383        QueryError, TableSchema as _, Text, Uint32, Value,
384    };
385    use wasm_dbms_macros::{DatabaseSchema, Table};
386    use wasm_dbms_memory::prelude::HeapMemoryProvider;
387
388    use crate::prelude::{DbmsContext, WasmDbmsDatabase};
389
390    // Use tables WITHOUT foreign key constraints so we can test all join
391    // types including unmatched rows without FK validation failures.
392
393    #[derive(Debug, Table, Clone, PartialEq, Eq)]
394    #[table = "departments"]
395    pub struct Department {
396        #[primary_key]
397        pub id: Uint32,
398        pub name: Text,
399        pub code: Nullable<Uint32>,
400    }
401
402    #[derive(Debug, Table, Clone, PartialEq, Eq)]
403    #[table = "employees"]
404    pub struct Employee {
405        #[primary_key]
406        pub id: Uint32,
407        pub name: Text,
408        pub dept_id: Uint32,
409        pub dept_code: Nullable<Uint32>,
410    }
411
412    #[derive(DatabaseSchema)]
413    #[tables(Department = "departments", Employee = "employees")]
414    pub struct TestSchema;
415
416    #[derive(Debug, Table, Clone, PartialEq, Eq)]
417    #[table = "indexed_departments"]
418    pub struct IndexedDepartment {
419        #[primary_key]
420        pub id: Uint32,
421        pub name: Text,
422    }
423
424    #[derive(Debug, Table, Clone, PartialEq, Eq)]
425    #[table = "indexed_employees"]
426    pub struct IndexedEmployee {
427        #[primary_key]
428        pub id: Uint32,
429        pub name: Text,
430        #[index]
431        pub dept_id: Uint32,
432    }
433
434    #[derive(DatabaseSchema)]
435    #[tables(
436        IndexedDepartment = "indexed_departments",
437        IndexedEmployee = "indexed_employees"
438    )]
439    pub struct IndexedJoinSchema;
440
441    fn setup() -> DbmsContext<HeapMemoryProvider> {
442        let ctx = DbmsContext::new(HeapMemoryProvider::default());
443        TestSchema::register_tables(&ctx).unwrap();
444        ctx
445    }
446
447    fn setup_indexed() -> DbmsContext<HeapMemoryProvider> {
448        let ctx = DbmsContext::new(HeapMemoryProvider::default());
449        IndexedJoinSchema::register_tables(&ctx).unwrap();
450        ctx
451    }
452
453    fn insert_dept(db: &WasmDbmsDatabase<'_, HeapMemoryProvider>, id: u32, name: &str) {
454        let insert = DepartmentInsertRequest::from_values(&[
455            (Department::columns()[0], Value::Uint32(Uint32(id))),
456            (
457                Department::columns()[1],
458                Value::Text(Text(name.to_string())),
459            ),
460        ])
461        .unwrap();
462        db.insert::<Department>(insert).unwrap();
463    }
464
465    fn insert_emp(
466        db: &WasmDbmsDatabase<'_, HeapMemoryProvider>,
467        id: u32,
468        name: &str,
469        dept_id: u32,
470    ) {
471        let insert = EmployeeInsertRequest::from_values(&[
472            (Employee::columns()[0], Value::Uint32(Uint32(id))),
473            (Employee::columns()[1], Value::Text(Text(name.to_string()))),
474            (Employee::columns()[2], Value::Uint32(Uint32(dept_id))),
475        ])
476        .unwrap();
477        db.insert::<Employee>(insert).unwrap();
478    }
479
480    fn insert_indexed_dept(db: &WasmDbmsDatabase<'_, HeapMemoryProvider>, id: u32, name: &str) {
481        let insert = IndexedDepartmentInsertRequest::from_values(&[
482            (IndexedDepartment::columns()[0], Value::Uint32(Uint32(id))),
483            (
484                IndexedDepartment::columns()[1],
485                Value::Text(Text(name.to_string())),
486            ),
487        ])
488        .unwrap();
489        db.insert::<IndexedDepartment>(insert).unwrap();
490    }
491
492    fn insert_indexed_emp(
493        db: &WasmDbmsDatabase<'_, HeapMemoryProvider>,
494        id: u32,
495        name: &str,
496        dept_id: u32,
497    ) {
498        let insert = IndexedEmployeeInsertRequest::from_values(&[
499            (IndexedEmployee::columns()[0], Value::Uint32(Uint32(id))),
500            (
501                IndexedEmployee::columns()[1],
502                Value::Text(Text(name.to_string())),
503            ),
504            (
505                IndexedEmployee::columns()[2],
506                Value::Uint32(Uint32(dept_id)),
507            ),
508        ])
509        .unwrap();
510        db.insert::<IndexedEmployee>(insert).unwrap();
511    }
512
513    #[test]
514    fn test_inner_join_does_not_match_null_keys() {
515        let ctx = setup();
516        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
517        insert_dept(&db, 1, "eng");
518        insert_dept(&db, 2, "hr");
519        insert_emp(&db, 10, "alice", 1);
520        insert_emp(&db, 11, "bob", 1);
521
522        let query = Query::builder()
523            .all()
524            .inner_join("employees", "code", "dept_code")
525            .build();
526        let results = db.select_join("departments", query).unwrap();
527        assert!(results.is_empty());
528    }
529
530    #[test]
531    fn test_outer_join_keeps_rows_with_null_keys_unmatched() {
532        let ctx = setup();
533        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
534        insert_dept(&db, 1, "eng");
535        insert_emp(&db, 10, "alice", 1);
536
537        let query = Query::builder()
538            .all()
539            .full_join("employees", "code", "dept_code")
540            .build();
541        let results = db.select_join("departments", query).unwrap();
542        assert_eq!(results.len(), 2);
543        let non_null_ids: Vec<Vec<(&str, Value)>> = results
544            .iter()
545            .map(|row| {
546                row.iter()
547                    .filter(|(column, value)| column.name == "id" && !value.is_null())
548                    .map(|(column, value)| {
549                        (
550                            column
551                                .table
552                                .as_deref()
553                                .expect("join column must name its table"),
554                            value.clone(),
555                        )
556                    })
557                    .collect()
558            })
559            .collect();
560        assert_eq!(
561            non_null_ids,
562            vec![
563                vec![("departments", Value::from(1u32))],
564                vec![("employees", Value::from(10u32))],
565            ]
566        );
567    }
568
569    #[test]
570    fn test_inner_join() {
571        let ctx = setup();
572        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
573        insert_dept(&db, 1, "eng");
574        insert_dept(&db, 2, "hr");
575        insert_emp(&db, 10, "alice", 1);
576        insert_emp(&db, 11, "bob", 1);
577
578        let query = Query::builder()
579            .all()
580            .inner_join("employees", "id", "dept_id")
581            .build();
582        let results = db.select_join("departments", query).unwrap();
583        // eng has 2 employees, hr has 0 → 2 rows
584        assert_eq!(results.len(), 2);
585    }
586
587    #[test]
588    fn test_left_join() {
589        let ctx = setup();
590        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
591        insert_dept(&db, 1, "eng");
592        insert_dept(&db, 2, "hr");
593        insert_emp(&db, 10, "alice", 1);
594
595        let query = Query::builder()
596            .all()
597            .left_join("employees", "id", "dept_id")
598            .build();
599        let results = db.select_join("departments", query).unwrap();
600        // eng has 1 employee, hr has 0 but LEFT keeps unmatched left → 2 rows
601        assert_eq!(results.len(), 2);
602
603        // Find hr's row: employee columns should be Null
604        let hr_row = results
605            .iter()
606            .find(|row| {
607                row.iter().any(|(col, val)| {
608                    col.name == "name"
609                        && col.table.as_deref() == Some("departments")
610                        && *val == Value::Text(Text("hr".to_string()))
611                })
612            })
613            .expect("hr should be in results");
614
615        // hr's employee name should be Null
616        let emp_name = hr_row
617            .iter()
618            .find(|(col, _)| col.name == "name" && col.table.as_deref() == Some("employees"))
619            .expect("employee name column should exist for hr");
620        assert_eq!(emp_name.1, Value::Null);
621    }
622
623    #[test]
624    fn test_right_join() {
625        let ctx = setup();
626        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
627        insert_dept(&db, 1, "eng");
628        insert_emp(&db, 10, "alice", 1);
629        // charlie references dept 999 which doesn't exist (no FK constraint)
630        insert_emp(&db, 11, "charlie", 999);
631
632        let query = Query::builder()
633            .all()
634            .right_join("employees", "id", "dept_id")
635            .build();
636        let results = db.select_join("departments", query).unwrap();
637        // alice matches eng, charlie (dept_id=999) is unmatched right → 2 rows
638        assert_eq!(results.len(), 2);
639
640        // charlie should have null department columns
641        let charlie_row = results
642            .iter()
643            .find(|row| {
644                row.iter().any(|(col, val)| {
645                    col.name == "name"
646                        && col.table.as_deref() == Some("employees")
647                        && *val == Value::Text(Text("charlie".to_string()))
648                })
649            })
650            .expect("charlie should be in results");
651
652        let dept_name = charlie_row
653            .iter()
654            .find(|(col, _)| col.name == "name" && col.table.as_deref() == Some("departments"))
655            .expect("department name column should exist for charlie");
656        assert_eq!(dept_name.1, Value::Null);
657    }
658
659    #[test]
660    fn test_full_join() {
661        let ctx = setup();
662        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
663        insert_dept(&db, 1, "eng");
664        insert_dept(&db, 2, "hr");
665        insert_emp(&db, 10, "alice", 1);
666        // charlie references dept 999 which doesn't exist
667        insert_emp(&db, 11, "charlie", 999);
668
669        let query = Query::builder()
670            .all()
671            .full_join("employees", "id", "dept_id")
672            .build();
673        let results = db.select_join("departments", query).unwrap();
674        // eng-alice matched (1), hr unmatched left (1), charlie unmatched right (1) = 3
675        assert_eq!(results.len(), 3);
676    }
677
678    #[test]
679    fn test_join_with_filter() {
680        let ctx = setup();
681        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
682        insert_dept(&db, 1, "eng");
683        insert_dept(&db, 2, "hr");
684        insert_emp(&db, 10, "alice", 1);
685        insert_emp(&db, 11, "bob", 2);
686
687        let query = Query::builder()
688            .all()
689            .inner_join("employees", "id", "dept_id")
690            .and_where(Filter::eq(
691                "departments.name",
692                Value::Text(Text("eng".to_string())),
693            ))
694            .build();
695        let results = db.select_join("departments", query).unwrap();
696        assert_eq!(results.len(), 1);
697    }
698
699    #[test]
700    fn test_join_with_order_by() {
701        let ctx = setup();
702        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
703        insert_dept(&db, 1, "eng");
704        insert_dept(&db, 2, "hr");
705        insert_emp(&db, 10, "zzz", 1);
706        insert_emp(&db, 11, "aaa", 2);
707
708        let query = Query::builder()
709            .all()
710            .inner_join("employees", "id", "dept_id")
711            .order_by_asc("employees.name")
712            .build();
713        let results = db.select_join("departments", query).unwrap();
714        assert_eq!(results.len(), 2);
715        let first_name = results[0]
716            .iter()
717            .find(|(col, _)| col.name == "name" && col.table.as_deref() == Some("employees"))
718            .unwrap();
719        assert_eq!(first_name.1, Value::Text(Text("aaa".to_string())));
720    }
721
722    #[test]
723    fn test_join_with_limit() {
724        let ctx = setup();
725        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
726        insert_dept(&db, 1, "eng");
727        insert_dept(&db, 2, "hr");
728        insert_emp(&db, 10, "alice", 1);
729        insert_emp(&db, 11, "bob", 2);
730
731        let query = Query::builder()
732            .all()
733            .inner_join("employees", "id", "dept_id")
734            .limit(1)
735            .build();
736        let results = db.select_join("departments", query).unwrap();
737        assert_eq!(results.len(), 1);
738    }
739
740    #[test]
741    fn test_join_with_offset() {
742        let ctx = setup();
743        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
744        insert_dept(&db, 1, "eng");
745        insert_dept(&db, 2, "hr");
746        insert_emp(&db, 10, "alice", 1);
747        insert_emp(&db, 11, "bob", 2);
748
749        let query = Query::builder()
750            .all()
751            .inner_join("employees", "id", "dept_id")
752            .offset(1)
753            .build();
754        let results = db.select_join("departments", query).unwrap();
755        assert_eq!(results.len(), 1);
756    }
757
758    #[test]
759    fn test_join_with_column_selection() {
760        let ctx = setup();
761        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
762        insert_dept(&db, 1, "eng");
763        insert_emp(&db, 10, "alice", 1);
764
765        let query = Query::builder()
766            .field("departments.name")
767            .field("employees.name")
768            .inner_join("employees", "id", "dept_id")
769            .build();
770        let results = db.select_join("departments", query).unwrap();
771        assert_eq!(results.len(), 1);
772        assert_eq!(results[0].len(), 2);
773    }
774
775    #[test]
776    fn test_inner_join_empty_result() {
777        let ctx = setup();
778        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
779        insert_dept(&db, 1, "eng");
780        // No employees
781
782        let query = Query::builder()
783            .all()
784            .inner_join("employees", "id", "dept_id")
785            .build();
786        let results = db.select_join("departments", query).unwrap();
787        assert!(results.is_empty());
788    }
789
790    #[test]
791    fn test_join_offset_exceeding_results_returns_empty() {
792        let ctx = setup();
793        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
794        insert_dept(&db, 1, "eng");
795        insert_emp(&db, 10, "alice", 1);
796
797        let query = Query::builder()
798            .all()
799            .inner_join("employees", "id", "dept_id")
800            .offset(100)
801            .build();
802        let results = db.select_join("departments", query).unwrap();
803        assert!(results.is_empty());
804    }
805
806    #[test]
807    fn test_join_on_indexed_column() {
808        let ctx = setup_indexed();
809        let db = WasmDbmsDatabase::oneshot(&ctx, IndexedJoinSchema);
810        insert_indexed_dept(&db, 1, "eng");
811        insert_indexed_dept(&db, 2, "hr");
812        insert_indexed_emp(&db, 10, "alice", 1);
813        insert_indexed_emp(&db, 11, "bob", 2);
814
815        let query = Query::builder()
816            .all()
817            .inner_join(
818                "indexed_employees",
819                "indexed_departments.id",
820                "indexed_employees.dept_id",
821            )
822            .build();
823        let results = db.select_join("indexed_departments", query).unwrap();
824
825        assert_eq!(results.len(), 2);
826        assert!(results.iter().any(|row| {
827            row.iter().any(|(column, value)| {
828                column.name == "name"
829                    && column.table.as_deref() == Some("indexed_employees")
830                    && *value == Value::Text(Text("alice".to_string()))
831            })
832        }));
833    }
834
835    /// Returns the value of `table.column` in a joined row, if the column is present.
836    fn joined_value<'a>(
837        row: &'a [(JoinColumnDef, Value)],
838        table: &str,
839        column: &str,
840    ) -> Option<&'a Value> {
841        row.iter()
842            .find(|(col, _)| col.table.as_deref() == Some(table) && col.name == column)
843            .map(|(_, value)| value)
844    }
845
846    /// Asserts that `row` has every department and employee column, in schema
847    /// order, and that the columns of `null_table` are all `NULL`.
848    fn assert_complete_row_with_null_side(row: &[(JoinColumnDef, Value)], null_table: &str) {
849        let shape: Vec<(Option<&str>, &str)> = row
850            .iter()
851            .map(|(col, _)| (col.table.as_deref(), col.name.as_str()))
852            .collect();
853        let expected_shape: Vec<(Option<&str>, &str)> = Department::columns()
854            .iter()
855            .map(|col| (Some("departments"), col.name))
856            .chain(
857                Employee::columns()
858                    .iter()
859                    .map(|col| (Some("employees"), col.name)),
860            )
861            .collect();
862        assert_eq!(shape, expected_shape);
863
864        for (col, value) in row {
865            if col.table.as_deref() == Some(null_table) {
866                assert_eq!(
867                    *value,
868                    Value::Null,
869                    "{null_table}.{} must be NULL",
870                    col.name
871                );
872            }
873        }
874    }
875
876    #[test]
877    fn test_left_join_with_empty_right_table_keeps_null_right_columns() {
878        let ctx = setup();
879        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
880        insert_dept(&db, 1, "eng");
881
882        let query = Query::builder()
883            .all()
884            .left_join("employees", "departments.id", "employees.dept_id")
885            .build();
886        let results = db.select_join("departments", query).unwrap();
887
888        assert_eq!(results.len(), 1);
889        assert_complete_row_with_null_side(&results[0], "employees");
890        assert_eq!(
891            joined_value(&results[0], "departments", "name"),
892            Some(&Value::Text(Text("eng".to_string())))
893        );
894    }
895
896    #[test]
897    fn test_right_join_with_empty_left_table_keeps_null_left_columns() {
898        let ctx = setup();
899        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
900        insert_emp(&db, 10, "alice", 1);
901
902        let query = Query::builder()
903            .all()
904            .right_join("employees", "departments.id", "employees.dept_id")
905            .build();
906        let results = db.select_join("departments", query).unwrap();
907
908        assert_eq!(results.len(), 1);
909        assert_complete_row_with_null_side(&results[0], "departments");
910        assert_eq!(
911            joined_value(&results[0], "employees", "name"),
912            Some(&Value::Text(Text("alice".to_string())))
913        );
914    }
915
916    #[test]
917    fn test_full_join_with_empty_right_table_keeps_null_right_columns() {
918        let ctx = setup();
919        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
920        insert_dept(&db, 1, "eng");
921
922        let query = Query::builder()
923            .all()
924            .full_join("employees", "departments.id", "employees.dept_id")
925            .build();
926        let results = db.select_join("departments", query).unwrap();
927
928        assert_eq!(results.len(), 1);
929        assert_complete_row_with_null_side(&results[0], "employees");
930    }
931
932    #[test]
933    fn test_full_join_with_empty_left_table_keeps_null_left_columns() {
934        let ctx = setup();
935        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
936        insert_emp(&db, 10, "alice", 1);
937
938        let query = Query::builder()
939            .all()
940            .full_join("employees", "departments.id", "employees.dept_id")
941            .build();
942        let results = db.select_join("departments", query).unwrap();
943
944        assert_eq!(results.len(), 1);
945        assert_complete_row_with_null_side(&results[0], "departments");
946    }
947
948    #[test]
949    fn test_full_join_with_unmatched_rows_on_both_sides_has_stable_shape() {
950        let ctx = setup();
951        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
952        insert_dept(&db, 1, "eng");
953        insert_emp(&db, 10, "alice", 999);
954
955        let query = Query::builder()
956            .all()
957            .full_join("employees", "departments.id", "employees.dept_id")
958            .build();
959        let results = db.select_join("departments", query).unwrap();
960
961        assert_eq!(results.len(), 2);
962        let eng_row = results
963            .iter()
964            .find(|row| joined_value(row, "departments", "id") == Some(&Value::Uint32(Uint32(1))))
965            .expect("eng should be in results");
966        assert_complete_row_with_null_side(eng_row, "employees");
967        let alice_row = results
968            .iter()
969            .find(|row| joined_value(row, "employees", "id") == Some(&Value::Uint32(Uint32(10))))
970            .expect("alice should be in results");
971        assert_complete_row_with_null_side(alice_row, "departments");
972    }
973
974    #[test]
975    fn test_join_filter_with_ambiguous_column_returns_error() {
976        let ctx = setup();
977        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
978        insert_dept(&db, 1, "eng");
979        insert_emp(&db, 10, "alice", 1);
980
981        let query = Query::builder()
982            .all()
983            .inner_join("employees", "departments.id", "employees.dept_id")
984            .and_where(Filter::eq("id", Value::Uint32(Uint32(1))))
985            .build();
986        let result = db.select_join("departments", query);
987
988        assert!(
989            matches!(
990                &result,
991                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
992                    if message.contains("Ambiguous column 'id'")
993            ),
994            "expected ambiguous column error, got {result:?}"
995        );
996    }
997
998    #[test]
999    fn test_join_filter_with_out_of_scope_table_returns_error() {
1000        let ctx = setup();
1001        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1002        insert_dept(&db, 1, "eng");
1003        insert_emp(&db, 10, "alice", 1);
1004
1005        let query = Query::builder()
1006            .all()
1007            .inner_join("employees", "departments.id", "employees.dept_id")
1008            .and_where(Filter::eq("projects.id", Value::Uint32(Uint32(1))))
1009            .build();
1010        let result = db.select_join("departments", query);
1011
1012        assert!(
1013            matches!(
1014                &result,
1015                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1016                    if message.contains("Table 'projects' not in query scope")
1017            ),
1018            "expected out-of-scope table error, got {result:?}"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_join_filter_with_invalid_like_operand_returns_error() {
1024        let ctx = setup();
1025        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1026        insert_dept(&db, 1, "eng");
1027        insert_emp(&db, 10, "alice", 1);
1028
1029        let query = Query::builder()
1030            .all()
1031            .inner_join("employees", "departments.id", "employees.dept_id")
1032            .and_where(Filter::like("employees.dept_id", "1%"))
1033            .build();
1034        let result = db.select_join("departments", query);
1035
1036        assert!(
1037            matches!(
1038                &result,
1039                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1040                    if message.contains("LIKE operator can only be applied to Text values")
1041            ),
1042            "expected LIKE type error, got {result:?}"
1043        );
1044    }
1045
1046    #[test]
1047    fn test_join_filter_with_ambiguous_column_returns_error_for_empty_join() {
1048        let ctx = setup();
1049        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1050        let query = Query::builder()
1051            .all()
1052            .inner_join("employees", "departments.id", "employees.dept_id")
1053            .and_where(Filter::eq("id", Value::Uint32(Uint32(1))))
1054            .build();
1055
1056        let result = db.select_join("departments", query);
1057
1058        assert!(
1059            matches!(
1060                &result,
1061                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1062                    if message.contains("Ambiguous column 'id'")
1063            ),
1064            "expected ambiguous column error, got {result:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_join_filter_with_out_of_scope_table_returns_error_for_empty_join() {
1070        let ctx = setup();
1071        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1072        let query = Query::builder()
1073            .all()
1074            .inner_join("employees", "departments.id", "employees.dept_id")
1075            .and_where(Filter::eq("projects.id", Value::Uint32(Uint32(1))))
1076            .build();
1077
1078        let result = db.select_join("departments", query);
1079
1080        assert!(
1081            matches!(
1082                &result,
1083                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1084                    if message.contains("Table 'projects' not in query scope")
1085            ),
1086            "expected out-of-scope table error, got {result:?}"
1087        );
1088    }
1089
1090    #[test]
1091    fn test_join_filter_with_invalid_like_operand_returns_error_for_empty_join() {
1092        let ctx = setup();
1093        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1094        let query = Query::builder()
1095            .all()
1096            .inner_join("employees", "departments.id", "employees.dept_id")
1097            .and_where(Filter::like("employees.dept_id", "1%"))
1098            .build();
1099
1100        let result = db.select_join("departments", query);
1101
1102        assert!(
1103            matches!(
1104                &result,
1105                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1106                    if message.contains("LIKE operator can only be applied to Text values")
1107            ),
1108            "expected LIKE type error, got {result:?}"
1109        );
1110    }
1111
1112    #[test]
1113    fn test_left_join_like_filter_skips_null_padded_rows() {
1114        let ctx = setup();
1115        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1116        insert_dept(&db, 1, "eng");
1117        insert_dept(&db, 2, "hr");
1118        insert_emp(&db, 10, "alice", 1);
1119
1120        let query = Query::builder()
1121            .all()
1122            .left_join("employees", "departments.id", "employees.dept_id")
1123            .and_where(Filter::like("employees.name", "a%"))
1124            .build();
1125        let results = db.select_join("departments", query).unwrap();
1126
1127        assert_eq!(results.len(), 1);
1128        assert_eq!(
1129            joined_value(&results[0], "employees", "name"),
1130            Some(&Value::Text(Text("alice".to_string())))
1131        );
1132
1133        let query = Query::builder()
1134            .all()
1135            .left_join("employees", "departments.id", "employees.dept_id")
1136            .and_where(Filter::like("employees.name", "a%").not())
1137            .build();
1138        let results = db.select_join("departments", query).unwrap();
1139
1140        assert_eq!(results.len(), 1);
1141        assert_eq!(
1142            joined_value(&results[0], "departments", "name"),
1143            Some(&Value::Text(Text("hr".to_string())))
1144        );
1145        assert_complete_row_with_null_side(&results[0], "employees");
1146    }
1147
1148    #[test]
1149    fn test_join_filter_with_dangling_like_escape_returns_error() {
1150        let ctx = setup();
1151        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
1152        insert_dept(&db, 1, "eng");
1153        insert_emp(&db, 10, "abc", 1);
1154
1155        let query = Query::builder()
1156            .all()
1157            .inner_join("employees", "departments.id", "employees.dept_id")
1158            .and_where(Filter::like("employees.name", "abc\\"))
1159            .build();
1160        let result = db.select_join("departments", query);
1161
1162        assert!(
1163            matches!(
1164                &result,
1165                Err(DbmsError::Query(QueryError::InvalidQuery(message)))
1166                    if message.contains("Invalid LIKE pattern")
1167            ),
1168            "expected invalid LIKE pattern error, got {result:?}"
1169        );
1170    }
1171}