Join Engine
Join Engine
- Overview
- Architecture
- Processing Pipeline
- Nested-Loop Join Algorithm
- NULL Padding
- Column Resolution
- Output Format
- Limitations
Overview
The join engine executes cross-table join queries, combining rows from two or more tables based on column equality conditions. It supports four join types — INNER, LEFT, RIGHT, and FULL — and integrates with the existing query pipeline for filtering, ordering, pagination, and column selection.
The implementation lives in crates/ic-dbms-canister/src/dbms/join.rs.
Architecture
The engine is implemented as a generic struct:
pub struct JoinEngine<'a, Schema: ?Sized>
where
Schema: DatabaseSchema,
{
schema: &'a Schema,
}
Key design decisions:
Schema: ?Sized— The?Sizedbound allows the engine to work withBox<dyn DatabaseSchema>, which is how the API layer passes the schema at runtime.- Borrows
DatabaseSchema— The engine borrows the schema to read rows from tables viaschema.select(dbms, table, query). - Stateless — The engine holds no mutable state; it takes a
Queryand returns results in a single call.
The DatabaseSchema trait provides the select method that the engine uses to read all rows from each table involved in the join.
Processing Pipeline
The join() method processes a query through these steps:
┌──────────────────────────┐
│ 1. Read FROM table rows │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 2. For each JOIN clause: │◄──── left-to-right
│ Read right table rows │
│ Nested-loop join │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 3. Apply filter │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 4. Apply ordering │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 5. Apply offset │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 6. Apply limit │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 7. Flatten to output │
└──────────────────────────┘
- Read FROM table: All rows from the primary table are loaded using an unfiltered
Query::builder().all().build(). - Process JOINs: Each
Joinclause is processed left-to-right. For each clause, the right table is read in full, column references are resolved, and the nested-loop join is executed against the accumulated result. - Filter: The query’s filter is applied to the combined rows using
filter.matches_joined_row(), which supports qualifiedtable.columnreferences. - Order: Order-by clauses are applied in reverse (stable sort), so the primary sort key ends up correctly ordered.
- Offset: Rows are skipped according to the offset value.
- Limit: The result is truncated to the limit.
- Flatten: Each joined row is converted from the internal
JoinedRowrepresentation to the outputVec<(CandidColumnDef, Value)>format, applying column selection.
Nested-Loop Join Algorithm
All four join types are handled by a single nested_loop_join method using two boolean flags:
| Join Type | keep_unmatched_left |
keep_unmatched_right |
|---|---|---|
| INNER | false |
false |
| LEFT | true |
false |
| RIGHT | false |
true |
| FULL | true |
true |
The algorithm:
- For each left row, iterate over all right rows.
- If the left column value equals the right column value (and is not
None), emit a combined row and mark the right row as matched. - After scanning all right rows for a given left row: if
keep_unmatched_leftis true and no match was found, emit the left row with NULL-padded right columns. - After all left rows are processed: if
keep_unmatched_rightis true, emit each unmatched right row with NULL-padded left columns.
This unified approach avoids code duplication across join types while keeping the logic straightforward.
NULL Padding
When a row has no match on the opposite side (in LEFT, RIGHT, or FULL joins), the missing columns are filled with Value::Null. The engine determines which columns to pad by inspecting a sample row from the opposite table:
fn null_pad_columns(&self, sample_row: &[(ColumnDef, Value)]) -> Vec<(ColumnDef, Value)> {
sample_row
.iter()
.map(|(col, _)| (*col, Value::Null))
.collect()
}
This preserves the correct column definitions (name, type, nullability) while setting every value to NULL. If the opposite table is empty (no sample row available), the padded group has zero columns.
Column Resolution
Column references in join ON conditions, filters, and ordering can be either qualified or unqualified:
- Qualified:
"users.id"— explicitly specifies the table. - Unqualified:
"id"— defaults to the FROM table (for ON left-column) or the joined table (for ON right-column).
Resolution is handled by resolve_column_ref:
fn resolve_column_ref(&self, field: &str, default_table: &str) -> (String, &str) {
if let Some((table, column)) = field.split_once('.') {
(table.to_string(), column)
} else {
(default_table.to_string(), field)
}
}
For filters and ordering on joined results, the same qualified/unqualified pattern applies. Unqualified names are searched across all table groups in the row, returning the first match.
Output Format
Join results use CandidColumnDef instead of ColumnDef:
pub struct CandidColumnDef {
pub table: Option<String>, // Source table name
pub name: String,
pub data_type: DataTypeKind,
pub nullable: bool,
pub primary_key: bool,
}
The table field is Some(table_name) for join results, allowing consumers to distinguish columns that share the same name across different tables.
At the API layer, the generated select endpoint checks query.has_joins():
- With joins: Routes to
select_join, which usesJoinEngine. - Without joins: Routes to
select_raw, the standard single-table path.
Both paths return Vec<Vec<(CandidColumnDef, Value)>>, but for non-join queries the table field is None.
Limitations
- *O(nm) nested-loop join*: Each join performs a full nested-loop comparison. For two tables of size *n and m, this is O(n*m) per join clause.
- Full table scans: Both the FROM table and each joined table are read in full (no filter pushdown to individual tables).
- No index usage: The join engine does not use any indexing; all comparisons are linear scans.
- All rows loaded into memory: Every table involved in the join is fully materialized in memory before processing. This can be a concern for very large tables on the IC.
- Equality joins only: The ON condition only supports column equality (
left_col = right_col). Range conditions, expressions, and multi-column ON clauses are not supported.