1use 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
16type JoinedRow = Vec<(String, Vec<(ColumnDef, Value)>)>;
18
19type JoinedTableColumns = Vec<(String, &'static [ColumnDef])>;
21
22pub 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 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 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 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 #[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 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 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 fn null_pad_columns(&self, columns: &[ColumnDef]) -> Vec<(ColumnDef, Value)> {
309 columns.iter().map(|col| (*col, Value::Null)).collect()
310 }
311
312 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 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 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 #[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 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 assert_eq!(results.len(), 2);
602
603 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 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 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 assert_eq!(results.len(), 2);
639
640 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 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 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 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 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 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}